@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
package/dist/src/plan.js CHANGED
@@ -1,17 +1,30 @@
1
1
  import { createHash } from "node:crypto";
2
- import { formatUnitRef, isSha, parseUnitRef, projectFile } from "./diff.js";
2
+ import { formatUnitRef, isSha, isUnitId, parseUnitRef, projectFile } from "./diff.js";
3
3
  export const PLAN_SCHEMA = "follow-me/plan@1";
4
+ /** Titles past this many characters draw a warning; the prompt asks for 50 or fewer. */
5
+ export const LONG_TITLE = 72;
4
6
  const validated = Symbol("follow-me/validated");
5
7
  export function isCommentId(s) {
6
8
  return s.length > 0;
7
9
  }
10
+ export function isConcernId(s) {
11
+ return typeof s === "string" && /^k_[0-9a-f]{8}$/.test(s);
12
+ }
8
13
  export function isPlanVersion(n) {
9
14
  return typeof n === "number" && Number.isInteger(n) && n >= 1;
10
15
  }
11
- const STATUSES = ["addressed", "declined", "deferred"];
16
+ const STATUSES = ["addressed", "declined", "deferred", "answered"];
12
17
  function isResponseStatus(s) {
13
18
  return STATUSES.some((x) => x === s);
14
19
  }
20
+ const SEVERITIES = ["blocker", "major", "minor"];
21
+ const CONCERN_KINDS = ["bug", "missing-test", "naming", "risk"];
22
+ export function isSeverity(s) {
23
+ return SEVERITIES.some((x) => x === s);
24
+ }
25
+ function isConcernKind(s) {
26
+ return CONCERN_KINDS.some((x) => x === s);
27
+ }
15
28
  /** Narrows to an object whose named fields are still unknown, which holds for any object. */
16
29
  export function isFields(v) {
17
30
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -19,32 +32,150 @@ export function isFields(v) {
19
32
  export function isArray(v) {
20
33
  return Array.isArray(v);
21
34
  }
22
- const INVALID = Symbol("invalid narration");
23
- /** Absent, null and blank all mean no narration, so a planner may always write the field. */
24
- function parseNarration(v) {
35
+ const INVALID = Symbol("invalid text");
36
+ /** Absent, null and blank all mean none, so a planner may always write the field. */
37
+ function parseText(v) {
25
38
  if (v === undefined || v === null)
26
39
  return null;
27
40
  if (typeof v !== "string")
28
41
  return INVALID;
29
42
  return v.trim() === "" ? null : v;
30
43
  }
31
- function withNarration(narration) {
32
- return narration === null ? {} : { narration };
44
+ const MERMAID_KEYWORDS = [
45
+ "graph",
46
+ "flowchart",
47
+ "sequenceDiagram",
48
+ "classDiagram",
49
+ "stateDiagram",
50
+ "stateDiagram-v2",
51
+ "erDiagram",
52
+ "journey",
53
+ "gantt",
54
+ "pie",
55
+ "gitGraph",
56
+ "mindmap",
57
+ "timeline",
58
+ "quadrantChart",
59
+ "block-beta",
60
+ "%%{init",
61
+ ];
62
+ /** A diagram is Mermaid source, so its first line must name a diagram type. Else the reason. */
63
+ export function diagramProblem(diagram) {
64
+ const first = (diagram.split("\n").find((line) => line.trim() !== "") ?? "").trim();
65
+ const named = MERMAID_KEYWORDS.some((k) => first.startsWith(k) && !/[\w-]/.test(first.charAt(k.length)));
66
+ if (named)
67
+ return null;
68
+ return `expected Mermaid source whose first line starts with a diagram type such as flowchart, sequenceDiagram or stateDiagram-v2, got "${first.slice(0, 40)}"`;
69
+ }
70
+ /** A concern is { severity, kind, text, lines? }; lines are change indices, on a unit ref only. */
71
+ function parseConcern(v, onUnit) {
72
+ if (!isFields(v))
73
+ return "expected an object";
74
+ if (!isSeverity(v.severity))
75
+ return `severity: expected one of ${SEVERITIES.join(", ")}`;
76
+ if (!isConcernKind(v.kind))
77
+ return `kind: expected one of ${CONCERN_KINDS.join(", ")}`;
78
+ if (typeof v.text !== "string" || v.text.trim() === "")
79
+ return "text: expected a sentence";
80
+ const { severity, kind, text } = v;
81
+ if (v.lines === undefined || v.lines === null)
82
+ return { severity, kind, text };
83
+ if (!onUnit)
84
+ return "lines: only a concern on a unit ref has lines";
85
+ const lines = v.lines;
86
+ if (!isArray(lines) || lines.length === 0)
87
+ return "lines: expected a list of change indices";
88
+ const indices = lines.flatMap((n) => (Number.isInteger(n) && Number(n) >= 0 ? [Number(n)] : []));
89
+ if (indices.length !== lines.length)
90
+ return "lines: expected a list of change indices";
91
+ return { severity, kind, text, lines: [...new Set(indices)].sort((a, b) => a - b) };
92
+ }
93
+ /** Absent, null and [] all mean no concerns, so a planner may always write the field. */
94
+ function parseConcerns(v, onUnit) {
95
+ if (v === undefined || v === null)
96
+ return [];
97
+ if (!isArray(v))
98
+ return "expected an array";
99
+ const out = [];
100
+ for (const [i, raw] of v.entries()) {
101
+ const concern = parseConcern(raw, onUnit);
102
+ if (typeof concern === "string")
103
+ return `[${i}]: ${concern}`;
104
+ out.push(concern);
105
+ }
106
+ return out;
107
+ }
108
+ /** The optional narration, diagram and concerns of a commit or unit entry, or what is wrong. */
109
+ function parseExtras(v, onUnit) {
110
+ const narration = parseText(v.narration);
111
+ if (narration === INVALID)
112
+ return { field: "narration", error: "expected a string" };
113
+ const diagram = parseText(v.diagram);
114
+ if (diagram === INVALID)
115
+ return { field: "diagram", error: "expected a string" };
116
+ const problem = diagram === null ? null : diagramProblem(diagram);
117
+ if (problem !== null)
118
+ return { field: "diagram", error: problem };
119
+ const concerns = parseConcerns(v.concerns, onUnit);
120
+ if (typeof concerns === "string")
121
+ return { field: "concerns", error: concerns };
122
+ return withExtras(narration, diagram, concerns);
123
+ }
124
+ function withText(narration, diagram) {
125
+ return {
126
+ ...(narration === null ? {} : { narration }),
127
+ ...(diagram === null ? {} : { diagram }),
128
+ };
129
+ }
130
+ function withExtras(narration, diagram, concerns) {
131
+ return { ...withText(narration, diagram), ...(concerns.length === 0 ? {} : { concerns }) };
132
+ }
133
+ function serializeConcern(c) {
134
+ return {
135
+ severity: c.severity,
136
+ kind: c.kind,
137
+ text: c.text,
138
+ ...(c.lines === undefined ? {} : { lines: c.lines }),
139
+ };
33
140
  }
34
- /** A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration": "..." }. */
141
+ function serializeExtras(x) {
142
+ return {
143
+ ...withText(x.narration ?? null, x.diagram ?? null),
144
+ ...(x.concerns === undefined ? {} : { concerns: x.concerns.map(serializeConcern) }),
145
+ };
146
+ }
147
+ /** A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration", "diagram", "concerns" }. */
35
148
  function parseUnitEntry(u) {
36
149
  if (typeof u === "string")
37
150
  return parseUnitRef(u);
38
151
  if (!isFields(u) || typeof u.unit !== "string") {
39
- return { error: 'expected a unit ref string or { "unit", "narration" }' };
152
+ return {
153
+ error: 'expected a unit ref string or { "unit", "narration", "diagram", "concerns" }',
154
+ };
40
155
  }
41
156
  const ref = parseUnitRef(u.unit);
42
157
  if ("error" in ref)
43
158
  return ref;
44
- const narration = parseNarration(u.narration);
45
- if (narration === INVALID)
46
- return { error: "narration must be a string" };
47
- return { ...ref, ...withNarration(narration) };
159
+ const extras = parseExtras(u, true);
160
+ if ("error" in extras)
161
+ return { error: `${extras.field}: ${extras.error}` };
162
+ return { ...ref, ...extras };
163
+ }
164
+ /** { "commit": 3, "unit": "h_x" } with commit 1-based; absent and null mean not given. */
165
+ function parseAnswerAt(v) {
166
+ if (v === undefined || v === null)
167
+ return null;
168
+ if (!isFields(v))
169
+ return 'expected { "commit", "unit" }';
170
+ const { commit, unit } = v;
171
+ if (typeof commit !== "number" || !Number.isInteger(commit) || commit < 1) {
172
+ return "commit: expected a commit number counted from 1";
173
+ }
174
+ if (unit === undefined || unit === null)
175
+ return { commit };
176
+ if (typeof unit !== "string" || !isUnitId(unit))
177
+ return "unit: expected a unit id";
178
+ return { commit, unit };
48
179
  }
49
180
  /** Boundary parse of draft.json or plans/vN.json. Reports every malformed field. */
50
181
  export function parsePlanFile(draft) {
@@ -78,9 +209,9 @@ export function parsePlanFile(draft) {
78
209
  }
79
210
  if (typeof c.message !== "string")
80
211
  bad(`${at}.message`, "expected a string");
81
- const narration = parseNarration(c.narration);
82
- if (narration === INVALID)
83
- bad(`${at}.narration`, "expected a string");
212
+ const extras = parseExtras(c, false);
213
+ if ("error" in extras)
214
+ bad(`${at}.${extras.field}`, extras.error);
84
215
  const units = [];
85
216
  if (!isArray(c.units))
86
217
  bad(`${at}.units`, "expected an array of unit refs");
@@ -93,8 +224,8 @@ export function parsePlanFile(draft) {
93
224
  units.push(ref);
94
225
  }
95
226
  }
96
- if (typeof c.message === "string" && narration !== INVALID) {
97
- commits.push({ message: c.message, ...withNarration(narration), units });
227
+ if (typeof c.message === "string" && !("error" in extras)) {
228
+ commits.push({ message: c.message, ...extras, units });
98
229
  }
99
230
  }
100
231
  }
@@ -115,8 +246,19 @@ export function parsePlanFile(draft) {
115
246
  }
116
247
  else if (typeof r.note !== "string")
117
248
  bad(`${at}.note`, "expected a string");
118
- else
119
- responses.push({ comment: r.comment, status: r.status, note: r.note });
249
+ else {
250
+ const where = parseAnswerAt(r.at);
251
+ if (typeof where === "string")
252
+ bad(`${at}.at`, where);
253
+ else {
254
+ responses.push({
255
+ comment: r.comment,
256
+ status: r.status,
257
+ note: r.note,
258
+ ...(where === null ? {} : { at: where }),
259
+ });
260
+ }
261
+ }
120
262
  }
121
263
  }
122
264
  if (issues.length > 0 || base === null || tip === null)
@@ -131,12 +273,17 @@ export function serializePlanFile(file) {
131
273
  tip: file.tip,
132
274
  commits: file.commits.map((c) => ({
133
275
  message: c.message,
134
- ...withNarration(c.narration ?? null),
135
- units: c.units.map((ref) => ref.narration === undefined
276
+ ...serializeExtras(c),
277
+ units: c.units.map((ref) => ref.narration === undefined && ref.diagram === undefined && ref.concerns === undefined
136
278
  ? formatUnitRef(ref)
137
- : { unit: formatUnitRef(ref), narration: ref.narration }),
279
+ : { unit: formatUnitRef(ref), ...serializeExtras(ref) }),
280
+ })),
281
+ responses: file.responses.map((r) => ({
282
+ comment: r.comment,
283
+ status: r.status,
284
+ note: r.note,
285
+ ...(r.at === undefined ? {} : { at: r.at }),
138
286
  })),
139
- responses: file.responses.map((r) => ({ comment: r.comment, status: r.status, note: r.note })),
140
287
  };
141
288
  }
142
289
  export function planHash(file) {
@@ -307,7 +454,77 @@ function matchesTip(change, tip) {
307
454
  }
308
455
  return false;
309
456
  }
457
+ /** "k_" + 8 hex over the unit, the lines and the text: stable while the concern is unchanged. */
458
+ function deriveConcernId(unit, c) {
459
+ const hex = createHash("sha256")
460
+ .update(`${unit ?? ""}\0${(c.lines ?? []).join(",")}\0${c.text}`)
461
+ .digest("hex")
462
+ .slice(0, 8);
463
+ const id = `k_${hex}`;
464
+ if (!isConcernId(id))
465
+ throw new Error(`sha256 produced ${id}`);
466
+ return id;
467
+ }
468
+ /**
469
+ * Gives every concern its id and checks where it sits: lines must be change lines the commit
470
+ * takes, and a unit carries at most one concern across the whole plan.
471
+ */
472
+ function placeConcerns(diff, file, claims, issues) {
473
+ const ids = new Set();
474
+ const holders = new Map();
475
+ const placedPerCommit = file.commits.map((commit, k) => {
476
+ const placed = [];
477
+ const add = (c, unit) => {
478
+ const id = deriveConcernId(unit, c);
479
+ if (ids.has(id)) {
480
+ issues.push({ kind: "bad-concern", commit: k, reason: `${id} repeats another concern` });
481
+ return;
482
+ }
483
+ ids.add(id);
484
+ placed.push({ ...c, id, ...(unit === null ? {} : { unit }) });
485
+ };
486
+ for (const c of commit.concerns ?? [])
487
+ add(c, null);
488
+ commit.units.forEach((ref, j) => {
489
+ const concerns = ref.concerns ?? [];
490
+ if (concerns.length === 0)
491
+ return;
492
+ holders.set(ref.unit, [...(holders.get(ref.unit) ?? []), ...concerns.map(() => k)]);
493
+ const taken = claims.resolved[k]?.[j]?.select;
494
+ const unit = diff.units.get(ref.unit);
495
+ for (const c of concerns) {
496
+ const lines = c.lines ?? [];
497
+ if (lines.length > 0 && unit?.kind !== "hunk") {
498
+ issues.push({
499
+ kind: "bad-concern",
500
+ commit: k,
501
+ reason: `${ref.unit}: only a text hunk has change lines`,
502
+ });
503
+ continue;
504
+ }
505
+ const outside = taken?.kind === "lines" ? lines.filter((n) => !taken.changes.includes(n)) : [];
506
+ if (outside.length > 0) {
507
+ issues.push({
508
+ kind: "bad-concern",
509
+ commit: k,
510
+ reason: `${ref.unit}: concern lines ${outside.join(", ")} are not change lines this commit takes`,
511
+ });
512
+ }
513
+ else
514
+ add(c, ref.unit);
515
+ }
516
+ });
517
+ return placed;
518
+ });
519
+ for (const [unit, commits] of holders) {
520
+ if (commits.length > 1) {
521
+ issues.push({ kind: "too-many-concerns", unit, commits: [...new Set(commits)] });
522
+ }
523
+ }
524
+ return placedPerCommit;
525
+ }
310
526
  function project(diff, file, claims, issues) {
527
+ const concerns = placeConcerns(diff, file, claims, issues);
311
528
  const { stateAt, tipOf } = projector(diff, claims.owners);
312
529
  const present = new Map();
313
530
  for (const f of diff.textFiles.values())
@@ -318,6 +535,7 @@ function project(diff, file, claims, issues) {
318
535
  }
319
536
  const commits = file.commits.map((commit, k) => {
320
537
  const refs = claims.resolved[k] ?? [];
538
+ const mine = concerns[k] ?? [];
321
539
  const touched = [...new Set(refs.flatMap((r) => diff.units.get(r.unit)?.path ?? []))].sort();
322
540
  const changes = touched.map((path) => {
323
541
  const state = stateAt(path, k);
@@ -346,7 +564,8 @@ function project(diff, file, claims, issues) {
346
564
  return {
347
565
  index: k,
348
566
  message: commit.message,
349
- ...withNarration(commit.narration ?? null),
567
+ ...withText(commit.narration ?? null, commit.diagram ?? null),
568
+ ...(mine.length === 0 ? {} : { concerns: mine }),
350
569
  refs,
351
570
  changes,
352
571
  };
@@ -366,6 +585,26 @@ function project(diff, file, claims, issues) {
366
585
  }
367
586
  return commits;
368
587
  }
588
+ /**
589
+ * Why an "answered" response is not an answer, or null. It must answer a question and point at a
590
+ * commit, or a unit of it, that carries narration or a diagram to explain it.
591
+ */
592
+ function answerProblem(file, r, isQuestion) {
593
+ if (!isQuestion)
594
+ return "answered is only for questions; use addressed, declined or deferred";
595
+ if (r.at === undefined)
596
+ return 'an answer needs "at" naming the commit that explains it';
597
+ const commit = file.commits[r.at.commit - 1];
598
+ if (commit === undefined)
599
+ return `at.commit ${r.at.commit} is not a commit of this plan`;
600
+ const target = r.at.unit;
601
+ const ref = target === undefined ? undefined : commit.units.find((u) => u.unit === target);
602
+ if (target !== undefined && ref === undefined) {
603
+ return `commit ${r.at.commit} does not take ${target}`;
604
+ }
605
+ const explains = [commit, ref].some((x) => x?.narration !== undefined || x?.diagram !== undefined);
606
+ return explains ? null : `commit ${r.at.commit} has no narration or diagram to point at`;
607
+ }
369
608
  /**
370
609
  * Parses and checks a draft against the diff: every change line used exactly once, every
371
610
  * commit's tree valid, and the last commit equal to the tip. Collects all issues at once.
@@ -406,8 +645,25 @@ export function validatePlan(diff, draft, opts = {}) {
406
645
  const warnings = review === null
407
646
  ? []
408
647
  : review.comments
409
- .filter((c) => !answered.has(c.id))
648
+ .filter((c) => c.author !== "agent" && !answered.has(c.id))
410
649
  .map((c) => ({ kind: "unanswered", comment: c.id, review: review.planVersion }));
650
+ file.commits.forEach((c, commit) => {
651
+ const length = (c.message.split("\n")[0] ?? "").length;
652
+ if (length > LONG_TITLE)
653
+ warnings.push({ kind: "long-title", commit, length });
654
+ });
655
+ const asked = new Map(review?.comments.map((c) => [c.id, c.kind === "question"]));
656
+ for (const r of file.responses) {
657
+ const isQuestion = asked.get(r.comment);
658
+ if (isQuestion === undefined)
659
+ continue;
660
+ if (r.status === "addressed" && isQuestion) {
661
+ warnings.push({ kind: "question-addressed", comment: r.comment });
662
+ }
663
+ const problem = r.status === "answered" ? answerProblem(file, r, isQuestion) : null;
664
+ if (problem !== null)
665
+ issues.push({ kind: "bad-answer", comment: r.comment, reason: problem });
666
+ }
411
667
  if (issues.length > 0)
412
668
  return { ok: false, issues };
413
669
  return {