@app-vitals/agent-runtime 0.0.0-stage → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Hand-authored synthetic `claude -p --output-format stream-json --verbose`
3
+ * transcript exercising `agent/src/runtime/run-telemetry.ts` end-to-end through
4
+ * `_consumeStream`: first-turn baseline capture, turn/tool-call counting, and
5
+ * per-skill attribution with a sub-agent turn in the middle.
6
+ *
7
+ * NOTE: hand-authored per the documented public Claude Code CLI stream-json
8
+ * schema (the same shapes the fixtures beside this one use). Numbers are
9
+ * chosen so each attribution bucket's totals are distinct and checkable.
10
+ *
11
+ * Shape of the session:
12
+ * - one `system`/`init` line
13
+ * - `msg_1` (root): text only, usage = the first-turn baseline
14
+ * - `msg_2` (root): a `Skill` tool_use invoking "shipwright:task-store"
15
+ * — emitted twice with the same message id (dedupe), the second line
16
+ * carrying the usage
17
+ * - a `user` tool_result line (no usage)
18
+ * - `msg_3` (skill): first usage-bearing turn after the invoke — its
19
+ * input + cache_creation is the skill's invokeContextDelta; it also fires
20
+ * an `Agent` tool_use (subagent_type "Explore")
21
+ * - `sub_1` (agent): a sub-agent turn carrying `parent_tool_use_id`
22
+ * - `msg_4` (skill): a plain Read tool_use + usage, still charged to the skill
23
+ * - one terminal `result`/success line
24
+ */
25
+
26
+ import type { RunTelemetry } from "@app-vitals/agent-runtime/run-telemetry";
27
+
28
+ const SONNET = "claude-sonnet-4-6";
29
+ const SESSION_ID = "sess-skill-attribution";
30
+
31
+ export const lines: string[] = [
32
+ JSON.stringify({
33
+ type: "system",
34
+ subtype: "init",
35
+ session_id: SESSION_ID,
36
+ model: SONNET,
37
+ tools: ["Read", "Skill", "Agent"],
38
+ }),
39
+ JSON.stringify({
40
+ type: "assistant",
41
+ message: {
42
+ id: "msg_1",
43
+ role: "assistant",
44
+ model: SONNET,
45
+ content: [{ type: "text", text: "Starting." }],
46
+ usage: {
47
+ input_tokens: 2,
48
+ output_tokens: 10,
49
+ cache_read_input_tokens: 30_000,
50
+ cache_creation_input_tokens: 40_000,
51
+ },
52
+ },
53
+ session_id: SESSION_ID,
54
+ }),
55
+ // msg_2 — Skill invoke, first line has no usage yet
56
+ JSON.stringify({
57
+ type: "assistant",
58
+ message: {
59
+ id: "msg_2",
60
+ role: "assistant",
61
+ model: SONNET,
62
+ content: [
63
+ {
64
+ type: "tool_use",
65
+ id: "tu_skill",
66
+ name: "Skill",
67
+ input: { skill: "shipwright:task-store" },
68
+ },
69
+ ],
70
+ },
71
+ session_id: SESSION_ID,
72
+ }),
73
+ // msg_2 — same message id repeated, now with usage (must count once)
74
+ JSON.stringify({
75
+ type: "assistant",
76
+ message: {
77
+ id: "msg_2",
78
+ role: "assistant",
79
+ model: SONNET,
80
+ content: [
81
+ {
82
+ type: "tool_use",
83
+ id: "tu_skill",
84
+ name: "Skill",
85
+ input: { skill: "shipwright:task-store" },
86
+ },
87
+ ],
88
+ usage: {
89
+ input_tokens: 3,
90
+ output_tokens: 20,
91
+ cache_read_input_tokens: 70_000,
92
+ cache_creation_input_tokens: 100,
93
+ },
94
+ },
95
+ session_id: SESSION_ID,
96
+ }),
97
+ JSON.stringify({
98
+ type: "user",
99
+ message: {
100
+ role: "user",
101
+ content: [
102
+ {
103
+ type: "tool_result",
104
+ tool_use_id: "tu_skill",
105
+ content: "<skill body>",
106
+ },
107
+ ],
108
+ },
109
+ session_id: SESSION_ID,
110
+ }),
111
+ // msg_3 — first turn after the invoke: cache_creation carries the skill body
112
+ JSON.stringify({
113
+ type: "assistant",
114
+ message: {
115
+ id: "msg_3",
116
+ role: "assistant",
117
+ model: SONNET,
118
+ content: [
119
+ {
120
+ type: "tool_use",
121
+ id: "tu_agent",
122
+ name: "Agent",
123
+ input: { subagent_type: "Explore", prompt: "find things" },
124
+ },
125
+ ],
126
+ usage: {
127
+ input_tokens: 4,
128
+ output_tokens: 30,
129
+ cache_read_input_tokens: 70_100,
130
+ cache_creation_input_tokens: 9_000,
131
+ },
132
+ },
133
+ session_id: SESSION_ID,
134
+ }),
135
+ // sub_1 — sub-agent turn
136
+ JSON.stringify({
137
+ type: "assistant",
138
+ parent_tool_use_id: "tu_agent",
139
+ message: {
140
+ id: "sub_1",
141
+ role: "assistant",
142
+ model: SONNET,
143
+ content: [{ type: "text", text: "found it" }],
144
+ usage: {
145
+ input_tokens: 500,
146
+ output_tokens: 40,
147
+ cache_read_input_tokens: 0,
148
+ cache_creation_input_tokens: 0,
149
+ },
150
+ },
151
+ session_id: SESSION_ID,
152
+ }),
153
+ JSON.stringify({
154
+ type: "user",
155
+ message: {
156
+ role: "user",
157
+ content: [
158
+ { type: "tool_result", tool_use_id: "tu_agent", content: "report" },
159
+ ],
160
+ },
161
+ session_id: SESSION_ID,
162
+ }),
163
+ // msg_4 — still attributed to the skill
164
+ JSON.stringify({
165
+ type: "assistant",
166
+ message: {
167
+ id: "msg_4",
168
+ role: "assistant",
169
+ model: SONNET,
170
+ content: [{ type: "tool_use", id: "tu_read", name: "Read", input: {} }],
171
+ usage: {
172
+ input_tokens: 5,
173
+ output_tokens: 50,
174
+ cache_read_input_tokens: 79_100,
175
+ cache_creation_input_tokens: 200,
176
+ },
177
+ },
178
+ session_id: SESSION_ID,
179
+ }),
180
+ JSON.stringify({
181
+ type: "result",
182
+ subtype: "success",
183
+ result: "done",
184
+ session_id: SESSION_ID,
185
+ is_error: false,
186
+ usage: {
187
+ input_tokens: 514,
188
+ output_tokens: 150,
189
+ cache_read_input_tokens: 249_200,
190
+ cache_creation_input_tokens: 49_300,
191
+ },
192
+ total_cost_usd: 0.42,
193
+ modelUsage: {
194
+ [SONNET]: {
195
+ inputTokens: 514,
196
+ outputTokens: 150,
197
+ cacheReadInputTokens: 249_200,
198
+ cacheCreationInputTokens: 49_300,
199
+ costUSD: 0.42,
200
+ },
201
+ },
202
+ }),
203
+ ];
204
+
205
+ export const expectedTelemetry: RunTelemetry = {
206
+ firstTurn: {
207
+ model: SONNET,
208
+ messageId: "msg_1",
209
+ inputTokens: 2,
210
+ cacheCreationTokens: 40_000,
211
+ cacheReadTokens: 30_000,
212
+ contextTokens: 70_002,
213
+ },
214
+ turns: 5,
215
+ toolCalls: 3,
216
+ skillUsage: [
217
+ {
218
+ kind: "root",
219
+ name: "root",
220
+ invocations: 0,
221
+ turns: 1,
222
+ inputTokens: 2,
223
+ outputTokens: 10,
224
+ cacheReadTokens: 30_000,
225
+ cacheCreationTokens: 40_000,
226
+ invokeContextDelta: null,
227
+ },
228
+ {
229
+ kind: "skill",
230
+ name: "shipwright:task-store",
231
+ invocations: 1,
232
+ turns: 3,
233
+ inputTokens: 12,
234
+ outputTokens: 100,
235
+ cacheReadTokens: 219_200,
236
+ cacheCreationTokens: 9_300,
237
+ invokeContextDelta: 3 + 100,
238
+ },
239
+ {
240
+ kind: "agent",
241
+ name: "Explore",
242
+ invocations: 1,
243
+ turns: 1,
244
+ inputTokens: 500,
245
+ outputTokens: 40,
246
+ cacheReadTokens: 0,
247
+ cacheCreationTokens: 0,
248
+ invokeContextDelta: null,
249
+ },
250
+ ],
251
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hand-authored synthetic `claude -p --output-format stream-json --verbose`
3
+ * transcript: a session that is cut off mid-stream — two assistant turns are
4
+ * emitted but NO terminal `result` line ever arrives (truncated output /
5
+ * process killed).
6
+ *
7
+ * NOTE: hand-authored per the documented public schema, standing in for a real
8
+ * capture to be validated later. The parser must surface whatever per-model
9
+ * usage it accumulated from the `assistant` lines rather than discarding it.
10
+ */
11
+
12
+ const SONNET = "claude-sonnet-4-6";
13
+ const OPUS = "claude-opus-4-8";
14
+ const SESSION_ID = "sess-truncated";
15
+
16
+ export const lines: string[] = [
17
+ JSON.stringify({
18
+ type: "system",
19
+ subtype: "init",
20
+ session_id: SESSION_ID,
21
+ model: SONNET,
22
+ }),
23
+ JSON.stringify({
24
+ type: "assistant",
25
+ message: {
26
+ id: "msg_a",
27
+ role: "assistant",
28
+ model: SONNET,
29
+ content: [{ type: "text", text: "Working on it..." }],
30
+ usage: {
31
+ input_tokens: 100,
32
+ output_tokens: 40,
33
+ cache_read_input_tokens: 5,
34
+ cache_creation_input_tokens: 0,
35
+ },
36
+ },
37
+ session_id: SESSION_ID,
38
+ }),
39
+ JSON.stringify({
40
+ type: "assistant",
41
+ message: {
42
+ id: "msg_b",
43
+ role: "assistant",
44
+ model: OPUS,
45
+ content: [{ type: "text", text: "Almost done" }],
46
+ usage: {
47
+ input_tokens: 150,
48
+ output_tokens: 60,
49
+ cache_read_input_tokens: 0,
50
+ cache_creation_input_tokens: 10,
51
+ },
52
+ },
53
+ session_id: SESSION_ID,
54
+ }),
55
+ // stream is cut off here — no `result` line
56
+ ];
57
+
58
+ /**
59
+ * Everything the parser accumulated before the stream ended. costUSD is 0
60
+ * (never learned the authoritative cost — no result event arrived).
61
+ */
62
+ export const expectedAccumulated = {
63
+ [SONNET]: {
64
+ inputTokens: 100,
65
+ outputTokens: 40,
66
+ cacheReadInputTokens: 5,
67
+ cacheCreationInputTokens: 0,
68
+ costUSD: 0,
69
+ },
70
+ [OPUS]: {
71
+ inputTokens: 150,
72
+ outputTokens: 60,
73
+ cacheReadInputTokens: 0,
74
+ cacheCreationInputTokens: 10,
75
+ costUSD: 0,
76
+ },
77
+ };
78
+
79
+ /**
80
+ * The `session_id` carried on the leading `system`/`init` line — the only
81
+ * place a session id is available in this fixture, since the stream never
82
+ * reaches a terminal `result` event.
83
+ */
84
+ export const expectedSessionId = SESSION_ID;
package/format.ts ADDED
@@ -0,0 +1,283 @@
1
+ // Markdown → Slack mrkdwn converter
2
+ // Converts GitHub-flavored markdown to Slack format
3
+
4
+ const TABLE_REGEX =
5
+ /(\|[^\n]+\|\n)([ \t]*\|[ \t]*[-:]+[ \t]*(?:\|[ \t]*[-:]+[ \t]*)*\|[ \t]*\n)((?:[ \t]*\|[^\n]+\|\n?)*)/gm;
6
+
7
+ export function markdownToSlack(text: string): string {
8
+ return (
9
+ text
10
+ // Tables → code blocks (fallback for non-block contexts)
11
+ .replace(TABLE_REGEX, (match) => {
12
+ return `\`\`\`\n${match.trimEnd()}\n\`\`\``;
13
+ })
14
+ // Italic (but not bold) — must run before heading/bold so the
15
+ // lookbehind/lookahead can distinguish *italic* from **bold** in
16
+ // the original markdown input. After the bold pass, **bold** becomes
17
+ // *bold* and the regex would incorrectly convert it to _bold_ (italic).
18
+ .replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "_$1_")
19
+ // Headings → bold
20
+ .replace(/^#{1,6}\s+(.+)$/gm, "*$1*")
21
+ // Bold
22
+ .replace(/\*\*(.+?)\*\*/g, "*$1*")
23
+ // Inline code
24
+ .replace(/`([^`]+)`/g, "`$1`")
25
+ // Links
26
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
27
+ // Horizontal rules
28
+ .replace(/^---+$/gm, "─────────────────────")
29
+ .trim()
30
+ );
31
+ }
32
+
33
+ const SECTION_TEXT_LIMIT = 3000;
34
+
35
+ type RawTextCell = { type: "raw_text"; text: string };
36
+ type TableBlock = {
37
+ type: "table";
38
+ column_settings: Array<{ align: "left" | "center" | "right" }>;
39
+ rows: RawTextCell[][];
40
+ };
41
+ type SectionBlock = { type: "section"; text: { type: "mrkdwn"; text: string } };
42
+ export type SlackBlock = TableBlock | SectionBlock;
43
+
44
+ function pushSectionBlocks(blocks: SlackBlock[], mrkdwn: string): void {
45
+ if (!mrkdwn) return;
46
+ for (let i = 0; i < mrkdwn.length; i += SECTION_TEXT_LIMIT) {
47
+ blocks.push({
48
+ type: "section",
49
+ text: { type: "mrkdwn", text: mrkdwn.slice(i, i + SECTION_TEXT_LIMIT) },
50
+ });
51
+ }
52
+ }
53
+
54
+ function parseTableRows(tableStr: string): string[][] {
55
+ const lines = tableStr.trim().split("\n").filter(Boolean);
56
+ const parseRow = (line: string) =>
57
+ line
58
+ .split("|")
59
+ .slice(1, -1)
60
+ .map((c) => c.trim());
61
+ // lines[0] = header, lines[1] = separator, lines[2+] = data rows
62
+ return [parseRow(lines[0]), ...lines.slice(2).map(parseRow)];
63
+ }
64
+
65
+ /**
66
+ * Converts markdown to Slack Block Kit blocks when tables are present.
67
+ * Returns null when no tables are found (fall back to markdownToSlack).
68
+ */
69
+ export function markdownToBlocks(
70
+ text: string,
71
+ ): { text: string; blocks: SlackBlock[] } | null {
72
+ const regex = new RegExp(TABLE_REGEX.source, TABLE_REGEX.flags);
73
+ if (!regex.test(text)) return null;
74
+
75
+ regex.lastIndex = 0;
76
+ const blocks: SlackBlock[] = [];
77
+ let lastIndex = 0;
78
+
79
+ for (let match = regex.exec(text); match !== null; match = regex.exec(text)) {
80
+ const before = text.slice(lastIndex, match.index).trim();
81
+ if (before) {
82
+ pushSectionBlocks(blocks, markdownToSlack(before));
83
+ }
84
+
85
+ const rows = parseTableRows(match[0]);
86
+ const columnCount = rows[0]?.length ?? 0;
87
+ blocks.push({
88
+ type: "table",
89
+ column_settings: Array.from({ length: columnCount }, () => ({
90
+ align: "left" as const,
91
+ })),
92
+ rows: rows.map((row) =>
93
+ // Slack's table block rejects a cell whose text is empty ("must be more
94
+ // than 0 characters"), which a blank corner/header cell produces and
95
+ // fails the whole message with invalid_blocks. Coerce empties to a
96
+ // non-breaking space: a real character (passes validation) that still
97
+ // renders blank.
98
+ row.map((cell) => ({
99
+ type: "raw_text" as const,
100
+ text: cell || " ",
101
+ })),
102
+ ),
103
+ });
104
+
105
+ lastIndex = match.index + match[0].length;
106
+ }
107
+
108
+ const after = text.slice(lastIndex).trim();
109
+ if (after) {
110
+ pushSectionBlocks(blocks, markdownToSlack(after));
111
+ }
112
+
113
+ return { text: markdownToSlack(text), blocks };
114
+ }
115
+
116
+ // ─── Slack rich_text → Markdown ──────────────────────────────────────────────
117
+
118
+ type TextStyle = {
119
+ bold?: boolean;
120
+ italic?: boolean;
121
+ code?: boolean;
122
+ strike?: boolean;
123
+ };
124
+
125
+ type RichTextElement =
126
+ | { type: "text"; text: string; style?: TextStyle }
127
+ | { type: "link"; url: string; text?: string }
128
+ | { type: "emoji"; name: string }
129
+ | { type: "user"; user_id: string }
130
+ | { type: string; [key: string]: unknown };
131
+
132
+ type RichTextSection = {
133
+ type: "rich_text_section";
134
+ elements: RichTextElement[];
135
+ };
136
+
137
+ type RichTextPreformatted = {
138
+ type: "rich_text_preformatted";
139
+ elements: RichTextElement[];
140
+ };
141
+
142
+ type RichTextList = {
143
+ type: "rich_text_list";
144
+ style: "bullet" | "ordered";
145
+ elements: RichTextSection[];
146
+ };
147
+
148
+ type RichTextQuote = {
149
+ type: "rich_text_quote";
150
+ elements: RichTextElement[];
151
+ };
152
+
153
+ type RichTextBlockElement =
154
+ | RichTextSection
155
+ | RichTextPreformatted
156
+ | RichTextList
157
+ | RichTextQuote
158
+ | { type: string; [key: string]: unknown };
159
+
160
+ type RichTextBlock = {
161
+ type: "rich_text";
162
+ elements: RichTextBlockElement[];
163
+ };
164
+
165
+ function isTextElement(
166
+ el: RichTextElement,
167
+ ): el is Extract<RichTextElement, { type: "text" }> {
168
+ return el.type === "text";
169
+ }
170
+
171
+ function isLinkElement(
172
+ el: RichTextElement,
173
+ ): el is Extract<RichTextElement, { type: "link" }> {
174
+ return el.type === "link";
175
+ }
176
+
177
+ function isEmojiElement(
178
+ el: RichTextElement,
179
+ ): el is Extract<RichTextElement, { type: "emoji" }> {
180
+ return el.type === "emoji";
181
+ }
182
+
183
+ function isUserElement(
184
+ el: RichTextElement,
185
+ ): el is Extract<RichTextElement, { type: "user" }> {
186
+ return el.type === "user";
187
+ }
188
+
189
+ function convertInlineElement(el: RichTextElement): string {
190
+ if (isTextElement(el)) {
191
+ const style = el.style ?? {};
192
+ let text = el.text;
193
+ if (style.code) text = `\`${text}\``;
194
+ if (style.bold) text = `**${text}**`;
195
+ if (style.italic) text = `*${text}*`;
196
+ if (style.strike) text = `~~${text}~~`;
197
+ return text;
198
+ }
199
+ if (isLinkElement(el)) {
200
+ return el.text ? `[${el.text}](${el.url})` : el.url;
201
+ }
202
+ if (isEmojiElement(el)) {
203
+ return `:${el.name}:`;
204
+ }
205
+ if (isUserElement(el)) {
206
+ return `<@${el.user_id}>`;
207
+ }
208
+ return "";
209
+ }
210
+
211
+ function convertSection(section: RichTextSection): string {
212
+ return section.elements.map(convertInlineElement).join("");
213
+ }
214
+
215
+ function convertRichTextBlock(block: RichTextBlock): string {
216
+ const parts: string[] = [];
217
+
218
+ for (const el of block.elements) {
219
+ if (el.type === "rich_text_section") {
220
+ parts.push(convertSection(el as RichTextSection));
221
+ } else if (el.type === "rich_text_preformatted") {
222
+ const pre = el as RichTextPreformatted;
223
+ const inner = pre.elements.map(convertInlineElement).join("");
224
+ parts.push(`\`\`\`\n${inner}\n\`\`\``);
225
+ } else if (el.type === "rich_text_list") {
226
+ const list = el as RichTextList;
227
+ const items = list.elements.map((item, i) => {
228
+ const text = convertSection(item);
229
+ return list.style === "ordered" ? `${i + 1}. ${text}` : `- ${text}`;
230
+ });
231
+ parts.push(items.join("\n"));
232
+ } else if (el.type === "rich_text_quote") {
233
+ const quote = el as RichTextQuote;
234
+ const inner = quote.elements.map(convertInlineElement).join("");
235
+ const quoted = inner
236
+ .split("\n")
237
+ .map((line) => `> ${line}`)
238
+ .join("\n");
239
+ parts.push(quoted);
240
+ }
241
+ }
242
+
243
+ return parts.join("\n\n");
244
+ }
245
+
246
+ /**
247
+ * Converts Slack rich_text blocks to markdown.
248
+ * Walks the blocks array for any `{type: "rich_text"}` block and converts
249
+ * its elements to markdown. Returns empty string if no rich_text block found.
250
+ */
251
+ export function richTextToMarkdown(blocks: unknown[]): string {
252
+ const richBlocks = blocks.filter(
253
+ (b): b is RichTextBlock =>
254
+ b != null &&
255
+ typeof b === "object" &&
256
+ "type" in b &&
257
+ b.type === "rich_text",
258
+ );
259
+
260
+ if (richBlocks.length === 0) return "";
261
+
262
+ return richBlocks.map(convertRichTextBlock).join("\n");
263
+ }
264
+
265
+ /**
266
+ * Builds a Slack message for a shareable plan/spec link: a single mrkdwn
267
+ * section block (`<url|View plan>`, the Slack link form `markdownToSlack`
268
+ * emits) plus a plain-text fallback. Used by the [plan:url] response marker.
269
+ */
270
+ export function formatPlanLink(url: string): {
271
+ text: string;
272
+ blocks: SlackBlock[];
273
+ } {
274
+ return {
275
+ text: `View plan: ${url}`,
276
+ blocks: [
277
+ {
278
+ type: "section",
279
+ text: { type: "mrkdwn", text: `<${url}|View plan>` },
280
+ },
281
+ ],
282
+ };
283
+ }