@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Whether a plan can be executed in parallel, and by whom (pure).
3
+ *
4
+ * The model proposes the split; this decides whether it is one. Nothing here
5
+ * reads the disk: the refusal rules are facts about the table the plan would be
6
+ * approved with, so they are testable in isolation and identical on every
7
+ * platform.
8
+ *
9
+ * A refusal is the point of the feature. A split whose units overlap, or whose
10
+ * dependency graph loops, or that hands a shared file to an owner, is what costs
11
+ * sessions rather than one tool call — so each rule names the offending pair
12
+ * instead of returning a boolean.
13
+ */
14
+
15
+ import { claimsConflict, findClaimOverlap, normalizeClaimPath } from "../claims/overlap.ts";
16
+ import { type PlanTeam, type PlanUnit, UNIT_NAME, unsafeValue } from "./team.ts";
17
+
18
+ /** Most non-lead units a team may have: the user switches to each one every round. */
19
+ export const MAX_OWNERS = 2;
20
+
21
+ /**
22
+ * Files every workstream touches, so an owner that writes one is a merge conflict
23
+ * by rule. The lead lands them; the project can extend the list through config.
24
+ */
25
+ export const SHARED_FILES: readonly string[] = [
26
+ "package.json",
27
+ "bun.lock",
28
+ "bun.lockb",
29
+ "package-lock.json",
30
+ "CHANGELOG.md",
31
+ "README.md",
32
+ "README.zh-CN.md",
33
+ "test/suite-manifest.ts",
34
+ "test/naming.test.ts",
35
+ "test/structure.test.ts",
36
+ "lib/tool-names.ts",
37
+ "lib/requirements.ts",
38
+ ];
39
+
40
+ /** What approval does with a proposed split. */
41
+ export type SplitCheck =
42
+ | { ok: true; verdict: "serial"; note: string }
43
+ | ({ ok: true; verdict: "pair" | "team" } & PlanTeam)
44
+ | { ok: false; reason: string };
45
+
46
+ /** A unit named in a refusal, so the message says which two collide. */
47
+ function unitAt(units: readonly PlanUnit[], index: number): string {
48
+ return units[index]?.name ?? `unit ${index + 1}`;
49
+ }
50
+
51
+ /** Whether `path` covers, or is covered by, any shared file. */
52
+ function sharedHit(path: string, shared: readonly string[]): string | undefined {
53
+ return shared.find((file) => claimsConflict(path, file));
54
+ }
55
+
56
+ /**
57
+ * Walk the `dependsOn` graph and report the first cycle as a readable path.
58
+ *
59
+ * A cycle means no unit can start, so it is refused rather than reordered: a
60
+ * dependency that points both ways usually means the split cut through one
61
+ * workstream instead of between two.
62
+ */
63
+ function findCycle(units: readonly PlanUnit[]): string[] | undefined {
64
+ const byName = new Map(units.map((unit) => [unit.name, unit]));
65
+ const state = new Map<string, "open" | "done">();
66
+ const path: string[] = [];
67
+
68
+ const visit = (name: string): string[] | undefined => {
69
+ const mark = state.get(name);
70
+ if (mark === "done") return undefined;
71
+ if (mark === "open") return [...path.slice(path.indexOf(name)), name];
72
+ state.set(name, "open");
73
+ path.push(name);
74
+ for (const next of byName.get(name)?.dependsOn ?? []) {
75
+ const cycle = visit(next);
76
+ if (cycle) return cycle;
77
+ }
78
+ path.pop();
79
+ state.set(name, "done");
80
+ return undefined;
81
+ };
82
+
83
+ for (const unit of units) {
84
+ const cycle = visit(unit.name);
85
+ if (cycle) return cycle;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Decide what approval does with `units`.
92
+ *
93
+ * No units means the plan approves as one flow, which is the common case and
94
+ * never a failure. One unit is refused with the skeleton advice: a single path
95
+ * proven end to end is what a split should be cut from, and approving it as a
96
+ * team would produce a table with nobody to join.
97
+ */
98
+ export function checkSplit(
99
+ units: readonly PlanUnit[] | undefined,
100
+ shared: readonly string[] = SHARED_FILES,
101
+ ): SplitCheck {
102
+ if (!units || units.length === 0) {
103
+ return { ok: true, verdict: "serial", note: "Approved as one flow." };
104
+ }
105
+ if (units.length === 1) {
106
+ return {
107
+ ok: false,
108
+ reason:
109
+ "A split needs at least two units. Approve as one flow, or build the thinnest end-to-end path first and approve a split once the boundary is real.",
110
+ };
111
+ }
112
+
113
+ const names = new Set<string>();
114
+ for (const unit of units) {
115
+ if (!UNIT_NAME.test(unit.name)) {
116
+ return { ok: false, reason: `"${unit.name}" is not a unit name; use lowercase letters, digits, and hyphens.` };
117
+ }
118
+ if (names.has(unit.name)) return { ok: false, reason: `Two units are both named "${unit.name}".` };
119
+ names.add(unit.name);
120
+ if (unit.paths.length === 0) return { ok: false, reason: `Unit "${unit.name}" claims no paths.` };
121
+ if (!unit.test.trim())
122
+ return { ok: false, reason: `Unit "${unit.name}" has no test; name the command that proves it.` };
123
+ }
124
+ const unsafe = unsafeValue({ lead: units.find((unit) => unit.lead)?.name ?? "", units: [...units] });
125
+ if (unsafe !== undefined) {
126
+ return { ok: false, reason: `"${unsafe}" would break the team table; remove the pipe or the line break.` };
127
+ }
128
+
129
+ const leads = units.filter((unit) => unit.lead);
130
+ if (leads.length !== 1) {
131
+ return {
132
+ ok: false,
133
+ reason:
134
+ leads.length === 0
135
+ ? "No unit is marked as the lead's; the approving session needs one unit of its own."
136
+ : `Units ${leads.map((unit) => `"${unit.name}"`).join(" and ")} are both marked lead; only one may be.`,
137
+ };
138
+ }
139
+ const lead = leads[0].name;
140
+ const owners = units.filter((unit) => !unit.lead);
141
+ if (owners.length > MAX_OWNERS) {
142
+ return {
143
+ ok: false,
144
+ reason: `A team has at most ${MAX_OWNERS} owners plus the lead; ${owners.length} owners means the split is wrong, not the team too small.`,
145
+ };
146
+ }
147
+
148
+ // Overlap is checked across units, naming the pair: the same predicate the bus
149
+ // applies to claims, so approval and claiming cannot disagree.
150
+ for (const unit of units) {
151
+ const inside = findClaimOverlap(unit.paths.map(normalizeClaimPath));
152
+ if (inside) {
153
+ return {
154
+ ok: false,
155
+ reason: `Unit "${unit.name}" lists paths that cover each other: ${inside[0]} and ${inside[1]}. Keep the narrower one.`,
156
+ };
157
+ }
158
+ }
159
+ for (let i = 0; i < units.length; i++) {
160
+ for (let j = i + 1; j < units.length; j++) {
161
+ for (const left of units[i].paths) {
162
+ for (const right of units[j].paths) {
163
+ if (claimsConflict(left, right)) {
164
+ return {
165
+ ok: false,
166
+ reason: `Units "${unitAt(units, i)}" and "${unitAt(units, j)}" overlap: ${left} and ${right}. Combine them or serialize.`,
167
+ };
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ for (const unit of units) {
175
+ if (unit.lead) continue;
176
+ const hit = unit.paths.flatMap((path) => sharedHit(path, shared)).at(0);
177
+ if (hit !== undefined) {
178
+ return {
179
+ ok: false,
180
+ reason: `Unit "${unit.name}" claims ${hit}, which every workstream touches; the lead lands shared files.`,
181
+ };
182
+ }
183
+ }
184
+
185
+ for (const unit of units) {
186
+ const unknown = unit.dependsOn.find((name) => !names.has(name));
187
+ if (unknown !== undefined) {
188
+ return { ok: false, reason: `Unit "${unit.name}" depends on "${unknown}", which is not a unit.` };
189
+ }
190
+ }
191
+ const cycle = findCycle(units);
192
+ if (cycle) return { ok: false, reason: `Dependencies loop: ${cycle.join(" → ")}.` };
193
+
194
+ return { ok: true, verdict: owners.length === 1 ? "pair" : "team", lead, units: units.map((unit) => ({ ...unit })) };
195
+ }
196
+
197
+ /**
198
+ * Merge order: the units in dependency order, ties broken by their place in the
199
+ * table, so the lead merges what everything else builds on first.
200
+ */
201
+ export function mergeOrder(units: readonly PlanUnit[]): string[] {
202
+ const placed: string[] = [];
203
+ const done = new Set<string>();
204
+ const remaining = [...units];
205
+ const names = new Set(units.map((unit) => unit.name));
206
+ while (remaining.length > 0) {
207
+ // The first unit whose dependencies are all placed. Approval refuses a cycle,
208
+ // so a search that finds nothing falls back to table order rather than looping.
209
+ const index = remaining.findIndex((unit) => unit.dependsOn.every((name) => done.has(name) || !names.has(name)));
210
+ const [next] = remaining.splice(index < 0 ? 0 : index, 1);
211
+ placed.push(next.name);
212
+ done.add(next.name);
213
+ }
214
+ return placed;
215
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where a repository's plans live (pure).
3
+ *
4
+ * One rule, two readers: `plan` writes here, and `session-bus` reads a team out of
5
+ * here to resolve a joining unit's paths without importing the plan extension.
6
+ * The directory is keyed by the repository — see `lib/repo-key.ts` — and a
7
+ * directory outside a repository has no key, which is the same store plans had
8
+ * before the key existed.
9
+ */
10
+
11
+ import { join } from "node:path";
12
+
13
+ /** The agent-directory entry plans are filed under. */
14
+ export const PLANS_DIRNAME = "plans";
15
+
16
+ /** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
17
+ export function plansDir(agentDir: string, key?: string): string {
18
+ return key === undefined ? join(agentDir, PLANS_DIRNAME) : join(agentDir, PLANS_DIRNAME, key);
19
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The plan's `## Team` section (pure): the split a plan was approved with.
3
+ *
4
+ * The section is a sentence naming the lead's unit plus a GFM table, because the
5
+ * review screen already renders a heading and a table — the block model has a
6
+ * `table` kind — so the split needs no renderer of its own, and the browser page
7
+ * anchors its comments to the same source lines everything else reads.
8
+ *
9
+ * Render and parse are the same module on purpose: the table's shape is a wire
10
+ * format between approval (`plan`) and joining (`session-bus`), which never
11
+ * imports this extension, so both sides must agree on one writer.
12
+ */
13
+
14
+ import { replaceSection, teamSectionBody } from "./sections.ts";
15
+
16
+ /** One workstream a plan was split into. */
17
+ export interface PlanUnit {
18
+ /** Lowercase slug used to address the unit; also the session label its owner takes. */
19
+ name: string;
20
+ /** Repository-relative path prefixes the unit owns. */
21
+ paths: string[];
22
+ /** The command that proves the unit is done. */
23
+ test: string;
24
+ /** Units that must land first. */
25
+ dependsOn: string[];
26
+ /** True for the one unit the approving session keeps. */
27
+ lead?: boolean;
28
+ }
29
+
30
+ /** A split as it was written into a plan. */
31
+ export interface PlanTeam {
32
+ /** The unit the approving session works. */
33
+ lead: string;
34
+ units: PlanUnit[];
35
+ }
36
+
37
+ /** Where the section's own heading lives, so a caller can name it in an error. */
38
+ export const TEAM_SECTION = "Team";
39
+
40
+ /** Unit names are addressed as session labels, so they take the same shape. */
41
+ export const UNIT_NAME = /^[a-z0-9][a-z0-9-]*$/;
42
+
43
+ /** A value that would break the table or a message. */
44
+ const UNSAFE = /[|\n\r]/;
45
+ const NONE = "—";
46
+
47
+ /**
48
+ * Render the section's body.
49
+ *
50
+ * Values are narrowed to one line so a path or a title cannot break the table it
51
+ * is written into; a caller that validates first gets a refusal instead of a
52
+ * mangled table.
53
+ */
54
+ export function renderTeam(team: PlanTeam): string {
55
+ const rows = team.units.map((unit) => {
56
+ const paths = unit.paths.join(", ");
57
+ const depends = unit.dependsOn.length > 0 ? unit.dependsOn.join(", ") : NONE;
58
+ return `| ${unit.name} | ${paths} | ${unit.test} | ${depends} |`;
59
+ });
60
+ return [`Lead: ${team.lead}`, "", "| unit | paths | test | depends on |", "| --- | --- | --- | --- |", ...rows].join(
61
+ "\n",
62
+ );
63
+ }
64
+
65
+ /** One cell's text, without the inline code a writer may have used. */
66
+ function clean(cell: string | undefined): string {
67
+ return (cell ?? "")
68
+ .trim()
69
+ .replace(/^`+|`+$/g, "")
70
+ .trim();
71
+ }
72
+
73
+ /** Split a table cell into its values, dropping backticks and padding. */
74
+ function cells(cell: string): string[] {
75
+ return cell
76
+ .split(",")
77
+ .map((value) =>
78
+ value
79
+ .trim()
80
+ .replace(/^`+|`+$/g, "")
81
+ .trim(),
82
+ )
83
+ .filter((value) => value !== "" && value !== NONE);
84
+ }
85
+
86
+ /**
87
+ * Parse a section body back into a team.
88
+ *
89
+ * Every malformed row is an error rather than a silent skip: a team read wrong is
90
+ * a session joining the wrong paths, which is the one failure this feature exists
91
+ * to prevent.
92
+ */
93
+ export function parseTeam(body: string): PlanTeam | { error: string } {
94
+ const lines = body.split("\n").map((line) => line.trim());
95
+ const leadLine = lines.find((line) => /^lead:/i.test(line));
96
+ const rows = lines.filter((line) => line.startsWith("|") && !/^\|\s*-{3}/.test(line));
97
+ const lead = clean(leadLine?.replace(/^lead:/i, ""));
98
+ if (!lead) return { error: "The team section has no `Lead: <unit>` line." };
99
+
100
+ const [header, ...data] = rows;
101
+ if (!header) return { error: "The team section has no table." };
102
+ const columns = header
103
+ .split("|")
104
+ .slice(1, -1)
105
+ .map((cell) => cell.trim().toLowerCase());
106
+ const at = (name: string): number => columns.indexOf(name);
107
+ const nameAt = at("unit");
108
+ const pathsAt = at("paths");
109
+ const testAt = at("test");
110
+ const dependsAt = at("depends on");
111
+ if (nameAt < 0 || pathsAt < 0 || testAt < 0) {
112
+ return { error: "The team table needs `unit`, `paths`, and `test` columns." };
113
+ }
114
+
115
+ const units: PlanUnit[] = [];
116
+ for (const row of data) {
117
+ const values = row.split("|").slice(1, -1);
118
+ const name = clean(values[nameAt]);
119
+ if (!name || !UNIT_NAME.test(name)) return { error: `"${name}" is not a unit name.` };
120
+ const paths = cells(values[pathsAt] ?? "");
121
+ const test = clean(values[testAt]);
122
+ if (paths.length === 0) return { error: `Unit "${name}" lists no paths.` };
123
+ if (!test) return { error: `Unit "${name}" lists no test.` };
124
+ const dependsOn = dependsAt < 0 ? [] : cells(values[dependsAt] ?? "");
125
+ units.push({ name, paths, test, dependsOn, ...(name === lead ? { lead: true } : {}) });
126
+ }
127
+ if (units.length === 0) return { error: "The team table has no units." };
128
+ if (!units.some((unit) => unit.name === lead)) return { error: `The lead "${lead}" is not one of the units.` };
129
+ return { lead, units };
130
+ }
131
+
132
+ /** The team a plan was approved with, or undefined when it has none. */
133
+ export function readTeam(plan: string): PlanTeam | { error: string } | undefined {
134
+ const body = teamSectionBody(plan);
135
+ return body === undefined ? undefined : parseTeam(body);
136
+ }
137
+
138
+ /** The plan with its team section rewritten. */
139
+ export function writeTeam(plan: string, team: PlanTeam): string {
140
+ return replaceSection(plan, TEAM_SECTION, renderTeam(team));
141
+ }
142
+
143
+ /** Whether any rendered value would break the table. */
144
+ export function unsafeValue(team: PlanTeam): string | undefined {
145
+ for (const unit of team.units) {
146
+ for (const value of [unit.name, unit.test, ...unit.paths, ...unit.dependsOn]) {
147
+ if (UNSAFE.test(value)) return value;
148
+ }
149
+ }
150
+ return UNSAFE.test(team.lead) ? team.lead : undefined;
151
+ }
package/lib/qr.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A QR code as terminal lines or an inline SVG.
3
+ *
4
+ * Uses the `qrcode` package only for the module matrix, then draws Unicode
5
+ * half-blocks for a terminal and hands the package's SVG to a page. The package
6
+ * loads on the first QR, not when an extension registers: a session that never
7
+ * shows one pays nothing for it. No ANSI is emitted; the caller decides colours,
8
+ * and the width is the matrix width plus a quiet zone.
9
+ *
10
+ * Two surfaces share this: `wechat` shows a login QR, and `plan` shows a review
11
+ * link a teammate can scan.
12
+ */
13
+
14
+ interface QrModules {
15
+ size: number;
16
+ get(row: number, col: number): boolean;
17
+ }
18
+
19
+ /** QR error-correction level, one setting for both surfaces. */
20
+ const ERROR_CORRECTION = "M" as const;
21
+
22
+ /** Build the module matrix for `text`, loading `qrcode` on first use. */
23
+ export async function qrMatrix(text: string): Promise<QrModules> {
24
+ const { default: QRCode } = await import("qrcode");
25
+ const qr = QRCode.create(text, { errorCorrectionLevel: ERROR_CORRECTION });
26
+ return qr.modules as unknown as QrModules;
27
+ }
28
+
29
+ /**
30
+ * The QR as terminal lines. Each string is exactly `matrix.size + 2 * quiet`
31
+ * columns wide (half-block characters are single width), so a caller can test
32
+ * `qrLines(text)[0].length` against the terminal width.
33
+ */
34
+ export async function qrLines(text: string, options: { quiet?: number } = {}): Promise<string[]> {
35
+ const quiet = Math.max(0, options.quiet ?? 2);
36
+ const matrix = await qrMatrix(text);
37
+ const size = matrix.size;
38
+ const isDark = (row: number, col: number): boolean =>
39
+ row >= 0 && col >= 0 && row < size && col < size && matrix.get(row, col);
40
+
41
+ const lines: string[] = [];
42
+ for (let y = -quiet; y < size + quiet; y += 2) {
43
+ let line = "";
44
+ for (let x = -quiet; x < size + quiet; x += 1) {
45
+ const top = isDark(y, x);
46
+ const bottom = isDark(y + 1, x);
47
+ line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
48
+ }
49
+ lines.push(line);
50
+ }
51
+ return lines;
52
+ }
53
+
54
+ /**
55
+ * The QR as an inline SVG, for a page that can paste markup instead of pixels.
56
+ *
57
+ * The markup is generated by the package from a URL this process built, so it
58
+ * carries no caller-supplied text; a page still serves it under its own CSP,
59
+ * which allows no external image.
60
+ */
61
+ export async function qrSvg(text: string): Promise<string> {
62
+ const { default: QRCode } = await import("qrcode");
63
+ return QRCode.toString(text, { type: "svg", errorCorrectionLevel: ERROR_CORRECTION, margin: 1 });
64
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The per-repository storage key that outlives one checkout.
3
+ *
4
+ * Two extensions file state by repository — `session-bus` its sessions, mail, and
5
+ * claims, `plan` its plans — and both must resolve the same key from any
6
+ * worktree, so a plan approved in `.pi/worktrees/x` is the same plan from the
7
+ * main checkout. The key is the repository's *common* git directory, which every
8
+ * worktree of that repository shares; outside a repository the working directory
9
+ * is the identity.
10
+ *
11
+ * Hashing keeps a mail directory, a claims directory, and a plans directory short
12
+ * enough to read and safe on every filesystem, while staying unique per
13
+ * repository.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { realpathSync } from "node:fs";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { createExecRunner, gitCommonDir, repoRoot } from "./git/index.ts";
20
+
21
+ /** Hex characters kept from the digest: collision-free in practice and short enough for a path. */
22
+ export const REPO_KEY_LENGTH = 12;
23
+
24
+ /** The key for one identity string, hashed and shortened. */
25
+ export function repoKeyOf(value: string): string {
26
+ return createHash("sha256").update(canonical(value)).digest("hex").slice(0, REPO_KEY_LENGTH);
27
+ }
28
+
29
+ /**
30
+ * One path, one spelling.
31
+ *
32
+ * git prints the path it was told to work from, so a repository reached through a
33
+ * symlinked prefix reports a different common dir for the same repository. Two
34
+ * spellings would file one repository's plans and claims under two keys, so the
35
+ * real path is what gets hashed; a path that cannot be resolved keeps its own
36
+ * spelling rather than failing.
37
+ */
38
+ function canonical(value: string): string {
39
+ try {
40
+ return realpathSync.native(value);
41
+ } catch {
42
+ return value;
43
+ }
44
+ }
45
+
46
+ /** A resolved repository identity. */
47
+ export interface RepoKey {
48
+ /** Absolute repository root, or `cwd` when git did not answer. */
49
+ root: string;
50
+ /** The shared key, derived from the common git directory. */
51
+ key: string;
52
+ /**
53
+ * Whether git resolved the repository. A caller that needs a *project-local*
54
+ * location branches on this; one that only needs a stable directory does not.
55
+ */
56
+ fromGit: boolean;
57
+ }
58
+
59
+ /**
60
+ * Resolve the repository identity for `cwd`.
61
+ *
62
+ * Never throws: without git, or when a git call fails, the working directory is
63
+ * the identity — which is what a caller outside a repository wants anyway.
64
+ */
65
+ export async function repoKeyFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<RepoKey> {
66
+ try {
67
+ const run = createExecRunner(pi);
68
+ const [common, root] = await Promise.all([gitCommonDir(run, cwd), repoRoot(run, cwd)]);
69
+ if (root) return { root, key: repoKeyOf(common ?? root), fromGit: true };
70
+ } catch {
71
+ // Fall through: no git, or a git call that failed, is the same answer here.
72
+ }
73
+ return { root: cwd, key: repoKeyOf(cwd), fromGit: false };
74
+ }