@100xprompt/brain 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 (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,656 @@
1
+ import { randomBytes, randomUUID } from "node:crypto"
2
+ import { copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, extname, join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { appSupport, exists, isDir, isRunning, readJson } from "../../fsutil.ts"
6
+ import type { Session, ToolKind, Turn, Usage } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { taskReader } from "./reader.ts"
10
+ import {
11
+ CLINE_TOOLS,
12
+ type Host,
13
+ type Mapping,
14
+ ROO_TOOLS,
15
+ SHOWN,
16
+ compareVersions,
17
+ continuedNote,
18
+ extensionVersion,
19
+ hostByApp,
20
+ stateDb,
21
+ storageDir,
22
+ summaryText,
23
+ } from "./records.ts"
24
+ import { backupDb, updateState } from "./vscdb.ts"
25
+
26
+ // Continues a portable session as a new task in Cline or Roo Code, written where the extension keeps its own
27
+ // (<app>/User/globalStorage/<extension>/tasks/<id>/) and listed in the extension's task history, so it opens
28
+ // from the History view like any task:
29
+ // api_conversation_history.json what the model sees: Anthropic-style messages with native tool_use and
30
+ // tool_result blocks, the protocol Cline 3.56 (next-gen models), Cline 4.x
31
+ // and Roo Code (3.38 default, 3.54 only) use
32
+ // ui_messages.json what the task panel shows: the task, replies, tool rows, command output
33
+ // Cline: task_metadata.json, and an entry in state/taskHistory.json (Cline 3.56 watches the file and picks up
34
+ // external changes; Cline 4.x reads it as its legacy task list and moves the task into its SDK store
35
+ // on resume, src/sdk/sdk-task-history.ts)
36
+ // Roo 3.49+: tasks/<id>/history_item.json, the source of truth its TaskHistoryStore reconciles from disk,
37
+ // and tasks/_index.json when it exists
38
+ // Roo before 3.49: an entry in the taskHistory of its globalState row in the app's state.vscdb, written
39
+ // only with the app closed and after a backup
40
+ //
41
+ // The task panel and the model read different files, so the note saying where the task came from is a text
42
+ // block in the first API message only: the model reads it, the panel does not show it. Tool calls are mapped
43
+ // to the extension's own tools where the shapes translate (records.ts) and keep their names otherwise. Every
44
+ // call gets a result (a missing one is closed as an error), and the folder is read back before it is listed.
45
+
46
+ interface Flavor {
47
+ /** Reader id. */
48
+ tool: string
49
+ label: string
50
+ /** The globalStorage folder name, which is the extension id in lower case. */
51
+ extension: string
52
+ /** The extension id as its publisher writes it: the key of its globalState row. */
53
+ stateKey: string
54
+ /** Versions whose task store and loader the writer was checked against. */
55
+ tested: string[]
56
+ tools: Partial<Record<ToolKind, Mapping>>
57
+ }
58
+
59
+ export const CLINE: Flavor = {
60
+ tool: "cline",
61
+ label: "Cline",
62
+ extension: "saoudrizwan.claude-dev",
63
+ stateKey: "saoudrizwan.claude-dev",
64
+ tested: ["3.56.2", "4.1.26"],
65
+ tools: CLINE_TOOLS,
66
+ }
67
+
68
+ export const ROO: Flavor = {
69
+ tool: "roo",
70
+ label: "Roo Code",
71
+ extension: "rooveterinaryinc.roo-cline",
72
+ stateKey: "RooVeterinaryInc.roo-cline",
73
+ tested: ["3.38.1", "3.54.0"],
74
+ tools: ROO_TOOLS,
75
+ }
76
+
77
+ /**
78
+ * Roo's roo-cline.customStoragePath setting moves its whole task store elsewhere (src/utils/storage.ts); the
79
+ * writer only knows the default place, so it refuses rather than write where Roo does not look.
80
+ */
81
+ function rooCustomStorage(home: string, app: string): string | undefined {
82
+ let text = ""
83
+ try {
84
+ text = readFileSync(join(appSupport(home), app, "User", "settings.json"), "utf8")
85
+ } catch {
86
+ return undefined
87
+ }
88
+ return text.match(/"roo-cline\.customStoragePath"\s*:\s*"([^"]+)"/)?.[1]
89
+ }
90
+
91
+ /** Roo moved its task list out of globalState into per-task files in 3.49.0 ("per-task file-based history store"). */
92
+ const ROO_FILE_INDEX = "3.49.0"
93
+ const usesStateDb = (f: Flavor, version: string) => f === ROO && compareVersions(version, ROO_FILE_INDEX) < 0
94
+
95
+ const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
96
+ const randomId = (prefix: string, n: number) => prefix + [...randomBytes(n)].map((b) => ALNUM[b % ALNUM.length]).join("")
97
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
98
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
99
+
100
+ /** Cline's ulid: 10 Crockford base32 characters of time, then 16 random ones. */
101
+ function ulid(ms: number): string {
102
+ const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
103
+ let t = ms
104
+ let time = ""
105
+ for (let i = 0; i < 10; i++) {
106
+ time = B32[t % 32]! + time
107
+ t = Math.floor(t / 32)
108
+ }
109
+ return time + [...randomBytes(16)].map((b) => B32[b % 32]).join("")
110
+ }
111
+
112
+ /** Roo's task ids are uuid v7: 48 bits of time, then random bits. */
113
+ function uuidv7(ms: number): string {
114
+ const b = randomBytes(16)
115
+ let t = BigInt(ms)
116
+ for (let i = 5; i >= 0; i--) {
117
+ b[i] = Number(t & 0xffn)
118
+ t >>= 8n
119
+ }
120
+ b[6] = (b[6]! & 0x0f) | 0x70
121
+ b[8] = (b[8]! & 0x3f) | 0x80
122
+ const h = b.toString("hex")
123
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
124
+ }
125
+
126
+ /** Tool names providers accept: letters, digits, _ and -, at most 64. */
127
+ const apiName = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
128
+
129
+ const asRecord = (v: unknown): Record<string, unknown> =>
130
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : { input: v ?? null }
131
+
132
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
133
+ const IMAGE_LIMIT = 5 * 1024 * 1024
134
+
135
+ type Block = Record<string, unknown>
136
+ type Call = Extract<Turn, { kind: "tool_call" }>
137
+ type Result = Extract<Turn, { kind: "tool_result" }>
138
+
139
+ interface ApiMsg {
140
+ role: "user" | "assistant"
141
+ content: Block[]
142
+ ts: number
143
+ /** Roo's own compaction: the summary, and the messages it replaced. */
144
+ isSummary?: true
145
+ condenseId?: string
146
+ condenseParent?: string
147
+ }
148
+
149
+ interface UiMsg {
150
+ ts: number
151
+ type: "say" | "ask"
152
+ say?: string
153
+ ask?: string
154
+ text?: string
155
+ images?: string[]
156
+ conversationHistoryIndex?: number
157
+ contextCondense?: Record<string, unknown>
158
+ }
159
+
160
+ export interface TaskPlan {
161
+ /** The two task files' records, in the order the extension stores them. */
162
+ api: Array<Record<string, unknown>>
163
+ ui: Array<Record<string, unknown>>
164
+ /** The task's history entry, less its size. */
165
+ item: Record<string, unknown>
166
+ /** Cline's task_metadata.json. */
167
+ metadata?: Record<string, unknown>
168
+ lost: string[]
169
+ /** What a read-back must find. */
170
+ expect: { calls: number }
171
+ }
172
+
173
+ /** The files of a new task `taskId` continuing `session`. Pure apart from reading attached images. */
174
+ export function planTask(
175
+ session: Session,
176
+ flavor: Flavor,
177
+ opts: { taskId: string; cwd: string; now: Date; version?: string },
178
+ ): TaskPlan {
179
+ const roo = flavor === ROO
180
+ // The first request is wrapped in <task> by Cline and by Roo 3.38; Roo 3.54 uses <user_message> throughout.
181
+ const taskTag = !roo || (opts.version !== undefined && usesStateDb(flavor, opts.version)) ? "task" : "user_message"
182
+ const drops = new Map<string, number>()
183
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
184
+ const label = toolLabel(session.header.tool)
185
+ const title = oneLine(session.header.title ?? firstUserText(session) ?? "") || `${label} session`
186
+ const results = new Map<string, Result>()
187
+ const callIds = new Set<string>()
188
+ for (const t of session.turns) {
189
+ if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
190
+ if (t.kind === "tool_call") callIds.add(t.id)
191
+ }
192
+
193
+ // Timestamps that never go backwards, from the session's own times where it has them.
194
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
195
+ let clock = (known ?? opts.now.getTime() - session.turns.length * 1000) - 1
196
+ const stamp = (at?: string) => {
197
+ const t = at ? Date.parse(at) : Number.NaN
198
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
199
+ return clock
200
+ }
201
+
202
+ const msgs: ApiMsg[] = []
203
+ const ui: UiMsg[] = []
204
+ // Cline dates each panel row by the API history's length when it appeared: the index of the last message.
205
+ const row = (fields: Omit<UiMsg, "ts">, ts: number, index: number) => {
206
+ const r: UiMsg = { ts, ...fields }
207
+ if (!roo) r.conversationHistoryIndex = index
208
+ ui.push(r)
209
+ return r
210
+ }
211
+ const note = { type: "text", text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }
212
+ const first: ApiMsg = { role: "user", content: [note], ts: stamp() }
213
+ msgs.push(first)
214
+ /** The user message new text joins, so roles alternate: the first message, or a message of tool results. */
215
+ let open: ApiMsg | undefined = first
216
+ let hasTask = false
217
+ const firstReal = session.turns.find((t) => t.kind === "user" || t.kind === "assistant" || t.kind === "tool_call" || t.kind === "compaction")
218
+ if (firstReal?.kind !== "user") {
219
+ // The panel's first row is the task; a session that starts mid-way gets its title there.
220
+ row({ type: "say", say: "text", text: title }, first.ts, -1)
221
+ hasTask = true
222
+ }
223
+
224
+ let cur: { msg: ApiMsg; index: number; req: UiMsg; usage: Required<Usage>; calls: Array<{ call: Call; id: string; name: string; input: Block }> } | undefined
225
+ let reasoning: Array<{ text: string; at?: string }> = []
226
+ let calls = 0
227
+ let tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
228
+
229
+ const flushReasoning = (index: number) => {
230
+ for (const r of reasoning) row({ type: "say", say: "reasoning", text: r.text }, stamp(r.at), index)
231
+ reasoning = []
232
+ }
233
+ const openAssistant = (at?: string) => {
234
+ if (cur) return cur
235
+ const index = msgs.length - 1
236
+ const ts = stamp(at)
237
+ const req = row({ type: "say", say: "api_req_started", text: "" }, ts, index)
238
+ flushReasoning(index)
239
+ cur = { msg: { role: "assistant", content: [], ts }, index, req, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, calls: [] }
240
+ return cur
241
+ }
242
+ const closeAssistant = () => {
243
+ if (!cur) return
244
+ const c = cur
245
+ cur = undefined
246
+ msgs.push(c.msg)
247
+ // Cline's api_req_started row carries the request's usage as JSON; the totals in the task header add these up.
248
+ c.req.text = JSON.stringify({
249
+ request: `Carried over from ${label}`,
250
+ tokensIn: c.usage.input,
251
+ tokensOut: c.usage.output,
252
+ cacheWrites: c.usage.cacheWrite,
253
+ cacheReads: c.usage.cacheRead,
254
+ cost: 0,
255
+ })
256
+ open = undefined
257
+ if (!c.calls.length) return
258
+ // Every tool_use is answered in the next user message, results first and in call order.
259
+ const res: ApiMsg = { role: "user", content: [], ts: stamp() }
260
+ for (const k of c.calls) {
261
+ const r = results.get(k.call.id)
262
+ if (!r) drop("tool calls without a result (closed as errors)")
263
+ const ok = r?.ok ?? false
264
+ let output = r?.output ?? "No result was recorded for this call; the session moved on before it finished."
265
+ if (r?.fullPath) output += `\n\n[Full output: ${r.fullPath}]`
266
+ if (!output.trim()) output = ok ? "(no output)" : "The call failed."
267
+ res.content.push({ type: "tool_result", tool_use_id: k.id, content: output, ...(ok ? {} : { is_error: true }) })
268
+ toolRows(k.name, k.input, output, ok, k.call.name, c.index, r?.at)
269
+ }
270
+ msgs.push(res)
271
+ open = res
272
+ }
273
+
274
+ /** The panel rows for one call, in the vocabulary each extension's chat view renders. */
275
+ const toolRows = (name: string, input: Block, output: string, ok: boolean, original: string, index: number, at?: string) => {
276
+ const s = (v: unknown) => (typeof v === "string" ? v : "")
277
+ const tool = (fields: Record<string, unknown>) =>
278
+ row(roo ? { type: "ask", ask: "tool", text: JSON.stringify(fields) } : { type: "say", say: "tool", text: JSON.stringify(fields) }, stamp(at), index)
279
+ switch (name) {
280
+ case "execute_command":
281
+ row(roo ? { type: "ask", ask: "command", text: s(input.command) } : { type: "say", say: "command", text: s(input.command) }, stamp(at), index)
282
+ row({ type: "say", say: "command_output", text: output }, stamp(at), index)
283
+ return
284
+ case "read_file":
285
+ tool({ tool: "readFile", path: s(input.path), content: s(input.path) })
286
+ return
287
+ case "write_to_file":
288
+ tool({ tool: "newFileCreated", path: s(input.path), content: s(input.content) })
289
+ return
290
+ case "replace_in_file":
291
+ tool({ tool: "editedExistingFile", path: s(input.path), diff: s(input.diff) })
292
+ return
293
+ case "search_replace":
294
+ tool({ tool: "editedExistingFile", path: s(input.file_path), diff: unifiedDiff(s(input.old_string), s(input.new_string)) })
295
+ return
296
+ case "search_files":
297
+ tool({ tool: "searchFiles", path: s(input.path), regex: s(input.regex), ...(input.file_pattern ? { filePattern: input.file_pattern } : {}), content: output })
298
+ return
299
+ case "list_files":
300
+ tool({ tool: input.recursive ? "listFilesRecursive" : "listFilesTopLevel", path: s(input.path), content: output })
301
+ return
302
+ case "web_fetch":
303
+ tool({ tool: "webFetch", path: s(input.url), content: output })
304
+ return
305
+ case "ask_followup_question":
306
+ row({ type: "ask", ask: "followup", text: JSON.stringify(roo ? { question: s(input.question), suggest: [] } : { question: s(input.question), options: [] }) }, stamp(at), index)
307
+ if (ok) row({ type: "say", say: "user_feedback", text: output }, stamp(at), index)
308
+ return
309
+ case "attempt_completion":
310
+ row({ type: "say", say: "completion_result", text: s(input.result) }, stamp(at), index)
311
+ return
312
+ }
313
+ const mcp = original.match(/^mcp__(.+?)__(.+)$/) ?? original.match(/^mcp--(.+?)--(.+)$/)
314
+ if (mcp) {
315
+ const server = { type: "use_mcp_tool", serverName: mcp[1], toolName: mcp[2], arguments: JSON.stringify(input) }
316
+ row(roo ? { type: "ask", ask: "use_mcp_server", text: JSON.stringify(server) } : { type: "say", say: "use_mcp_server", text: JSON.stringify(server) }, stamp(at), index)
317
+ row({ type: "say", say: "mcp_server_response", text: output }, stamp(at), index)
318
+ return
319
+ }
320
+ // A tool the panel has no row for is shown as a short note, so the person still sees it ran.
321
+ const cut = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)}…` : t)
322
+ const text = `\`${original}\` (carried over from ${label})\n\n\`\`\`\n${cut(JSON.stringify(input), 600)}\n\`\`\`\n\n${ok ? "" : "Failed: "}${cut(output, 600)}`
323
+ row({ type: "say", say: "info", text }, stamp(at), index)
324
+ }
325
+
326
+ const seen = new Set<string>()
327
+ let lastSummary = -1
328
+ for (const t of session.turns) {
329
+ switch (t.kind) {
330
+ case "user": {
331
+ closeAssistant()
332
+ flushReasoning(msgs.length - 1)
333
+ const blocks: Block[] = []
334
+ const images: string[] = []
335
+ let text = t.text
336
+ for (const a of t.attachments ?? []) {
337
+ if (!a.path) continue // bytes the reader could not keep; it already counted them
338
+ if (a.kind === "file") {
339
+ text += `\n\n[Attached file: ${a.path}]`
340
+ continue
341
+ }
342
+ const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
343
+ let size = Number.POSITIVE_INFINITY
344
+ try {
345
+ size = statSync(a.path).size
346
+ } catch {}
347
+ if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) {
348
+ const data = readFileSync(a.path).toString("base64")
349
+ blocks.push({ type: "image", source: { type: "base64", media_type: mime, data } })
350
+ images.push(`data:${mime};base64,${data}`)
351
+ } else drop("images that could not be attached")
352
+ }
353
+ if (!text.trim() && !blocks.length) break
354
+ const tag = hasTask ? "user_message" : taskTag
355
+ const wrapped = { type: "text", text: `<${tag}>\n${text}\n</${tag}>` }
356
+ const index = (open ? msgs.indexOf(open) : msgs.length) - 1
357
+ row({ type: "say", say: hasTask ? "user_feedback" : "text", text, ...(images.length ? { images } : {}) }, stamp(t.at), index)
358
+ hasTask = true
359
+ if (open) open.content.push(wrapped, ...blocks)
360
+ else {
361
+ open = { role: "user", content: [wrapped, ...blocks], ts: ui.at(-1)!.ts }
362
+ msgs.push(open)
363
+ }
364
+ break
365
+ }
366
+ case "assistant": {
367
+ if (!t.text.trim()) break
368
+ // Text after a tool call is a new response: it was written after the call's result came back.
369
+ if (cur?.calls.length) closeAssistant()
370
+ const c = openAssistant(t.at)
371
+ c.msg.content.push({ type: "text", text: t.text })
372
+ row({ type: "say", say: "text", text: t.text }, stamp(t.at), c.index)
373
+ if (t.usage) {
374
+ c.usage.input += t.usage.input ?? 0
375
+ c.usage.output += t.usage.output ?? 0
376
+ c.usage.cacheRead += t.usage.cacheRead ?? 0
377
+ c.usage.cacheWrite += t.usage.cacheWrite ?? 0
378
+ tokens = { input: tokens.input + (t.usage.input ?? 0), output: tokens.output + (t.usage.output ?? 0), cacheRead: tokens.cacheRead + (t.usage.cacheRead ?? 0), cacheWrite: tokens.cacheWrite + (t.usage.cacheWrite ?? 0) }
379
+ }
380
+ break
381
+ }
382
+ case "tool_call": {
383
+ if (seen.has(t.id)) break
384
+ seen.add(t.id)
385
+ const raw = asRecord(t.input)
386
+ const mapped = flavor.tools[t.toolKind]?.(raw, t.name)
387
+ if (mapped && mapped.name !== t.name) drop(`tool calls mapped to ${flavor.label}'s own tools`)
388
+ const name = mapped?.name ?? apiName(t.name)
389
+ const input = mapped?.input ?? raw
390
+ const c = openAssistant(t.at)
391
+ // A final answer or plan is both the text the person saw and the call's input; the call carries it.
392
+ const shown = SHOWN[name]
393
+ const last = c.msg.content.at(-1)
394
+ if (shown && last?.type === "text" && String(last.text).trim() === String(input[shown] ?? "").trim()) {
395
+ c.msg.content.pop()
396
+ for (let i = ui.length - 1; i >= 0; i--)
397
+ if (ui[i]!.say === "text" && ui[i]!.text === last.text) {
398
+ ui.splice(i, 1)
399
+ break
400
+ }
401
+ }
402
+ const id = randomId("toolu_01", 22)
403
+ c.msg.content.push({ type: "tool_use", id, name, input })
404
+ c.calls.push({ call: t, id, name, input })
405
+ calls++
406
+ break
407
+ }
408
+ case "tool_result":
409
+ // Written right after its call's response; a result closes the response it answers, so a call made
410
+ // after it is a new response. A result whose call never came is dropped.
411
+ if (!callIds.has(t.callId)) drop("tool results without a call")
412
+ else if (cur?.calls.some((k) => k.call.id === t.callId)) closeAssistant()
413
+ break
414
+ case "reasoning":
415
+ // Shown in the panel as the extensions show their own; the API history keeps only signed reasoning.
416
+ reasoning.push({ text: t.text, ...(t.at ? { at: t.at } : {}) })
417
+ drop("reasoning (shown in the task, not sent to the model)")
418
+ break
419
+ case "compaction": {
420
+ closeAssistant()
421
+ flushReasoning(msgs.length - 1)
422
+ const ts = stamp(t.at)
423
+ if (roo) {
424
+ // Roo's own non-destructive condense: every earlier message points at the summary, which the model
425
+ // reads from on (getEffectiveApiHistory); the note goes with it, since the first message is hidden.
426
+ // What the person says next joins the summary's message, as Roo 3.54 merges it before each request.
427
+ const condenseId = randomUUID()
428
+ for (const m of msgs) m.condenseParent ??= condenseId
429
+ open = { role: "user", content: [note, { type: "text", text: `## Conversation Summary\n${t.summary}` }], ts, isSummary: true, condenseId }
430
+ msgs.push(open)
431
+ row({ type: "say", say: "condense_context", contextCondense: { cost: 0, prevContextTokens: 0, newContextTokens: 0, summary: t.summary, condenseId } }, ts, msgs.length - 1)
432
+ } else {
433
+ // Cline's own truncation: conversationHistoryDeletedRange hides the messages between the first
434
+ // exchange and the summary from every request (ContextManager.getTruncatedMessages).
435
+ const index = (open ? msgs.indexOf(open) : msgs.length) - 1
436
+ const block = { type: "text", text: summaryText(label, t.summary) }
437
+ if (open) open.content.push(block)
438
+ else {
439
+ open = { role: "user", content: [block], ts }
440
+ msgs.push(open)
441
+ }
442
+ lastSummary = msgs.indexOf(open)
443
+ row({ type: "say", say: "tool", text: JSON.stringify({ tool: "summarizeTask", content: t.summary }) }, ts, index)
444
+ }
445
+ break
446
+ }
447
+ case "file_edit":
448
+ if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
449
+ break
450
+ case "subagent":
451
+ drop("subagent transcripts (their results stay in the parent's tool results)")
452
+ break
453
+ }
454
+ }
455
+ closeAssistant()
456
+ flushReasoning(msgs.length - 1)
457
+
458
+ const task = ui[0]?.text ?? title
459
+ const lastTs = ui.at(-1)?.ts ?? first.ts
460
+ const totals = { tokensIn: tokens.input, tokensOut: tokens.output, cacheWrites: tokens.cacheWrite, cacheReads: tokens.cacheRead, totalCost: 0 }
461
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
462
+ const api = msgs.map((m) =>
463
+ roo
464
+ ? { role: m.role, content: m.content, ts: m.ts, ...(m.isSummary ? { isSummary: true, condenseId: m.condenseId } : {}), ...(m.condenseParent ? { condenseParent: m.condenseParent } : {}) }
465
+ : { role: m.role, content: m.content },
466
+ )
467
+ const uiOut = ui.map((r) => ({ ...r }) as Record<string, unknown>)
468
+ if (roo) {
469
+ return {
470
+ api,
471
+ ui: uiOut,
472
+ // Listed by the time it was continued, so it sorts with today's tasks.
473
+ item: { id: opts.taskId, number: 1, ts: Math.max(lastTs, opts.now.getTime()), task, ...totals, workspace: opts.cwd, mode: "code", toolProtocol: "native" },
474
+ lost,
475
+ expect: { calls },
476
+ }
477
+ }
478
+ // The deleted range keeps the first exchange and starts again at the summary; with no exchange before it, nothing is hidden.
479
+ const range: [number, number] | undefined = lastSummary >= 3 ? [2, lastSummary - 1] : undefined
480
+ const model = session.header.models.at(-1)
481
+ return {
482
+ api,
483
+ ui: uiOut,
484
+ item: {
485
+ id: opts.taskId,
486
+ ulid: ulid(opts.now.getTime()),
487
+ ts: Math.max(lastTs, opts.now.getTime()),
488
+ task,
489
+ ...totals,
490
+ cwdOnTaskInitialization: opts.cwd,
491
+ ...(range ? { conversationHistoryDeletedRange: range } : {}),
492
+ isFavorited: false,
493
+ ...(model ? { modelId: model } : {}),
494
+ },
495
+ metadata: {
496
+ files_in_context: [],
497
+ model_usage: session.header.models.map((m) => ({ ts: first.ts, model_id: m, model_provider_id: providerOf(m), mode: "act" })),
498
+ environment_history: [],
499
+ },
500
+ lost,
501
+ expect: { calls },
502
+ }
503
+ }
504
+
505
+ /** The provider a model belongs to, for task_metadata's model_usage. */
506
+ function providerOf(model: string): string {
507
+ const slash = model.indexOf("/")
508
+ if (slash > 0) return model.slice(0, slash)
509
+ return /claude|opus|sonnet|haiku/i.test(model) ? "anthropic" : /gemini/i.test(model) ? "gemini" : /^(gpt|o\d|codex)/i.test(model) ? "openai" : "unknown"
510
+ }
511
+
512
+ /** A small unified diff of one replacement, which Roo's edit rows render. */
513
+ function unifiedDiff(before: string, after: string): string {
514
+ const lines = (s: string, p: string) => (s ? s.split("\n").map((l) => p + l) : [])
515
+ return ["@@", ...lines(before, "-"), ...lines(after, "+")].join("\n")
516
+ }
517
+
518
+ /** Writes a file through a temporary name, as the extensions do, so a watcher never sees half of it. */
519
+ function writeAtomic(path: string, data: string) {
520
+ const tmp = `${path}.tmp.${Date.now()}.${randomBytes(4).toString("hex")}`
521
+ writeFileSync(tmp, data)
522
+ renameSync(tmp, path)
523
+ }
524
+
525
+ const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, "-")
526
+
527
+ export interface TaskWriterOptions {
528
+ flavor: Flavor
529
+ /** The editor whose global storage holds the task: "Code" (VS Code), "Cursor" or "Windsurf". */
530
+ app: string
531
+ /** For tests: the installed extension version, and whether a process is running. */
532
+ version?: string
533
+ running?: (process: string) => boolean
534
+ }
535
+
536
+ export function taskWriter(o: TaskWriterOptions): Writer {
537
+ const host = hostByApp(o.app) as Host
538
+ const f = o.flavor
539
+ const tool = o.app === "Code" ? f.tool : `${f.tool}-${o.app.toLowerCase()}`
540
+ const label = o.app === "Code" ? f.label : `${f.label} (${o.app})`
541
+ const app = o.app === "Code" ? "VS Code" : o.app
542
+ const installed = (home: string) => o.version ?? extensionVersion(home, host.app, f.extension)
543
+ const running = () => (o.running ?? isRunning)(host.process)
544
+ // Cursor's global storage also holds Cursor's own chats, and Windsurf's its own state: never written while they run.
545
+ const mustClose = (version: string) => o.app !== "Code" || usesStateDb(f, version)
546
+
547
+ return {
548
+ tool,
549
+ label,
550
+ way: "native",
551
+
552
+ async ready(w: WriteOptions): Promise<Readiness> {
553
+ const v = installed(w.home)
554
+ if (!v) return { ok: false, why: `${f.label} is not installed in ${app}` }
555
+ if (!f.tested.includes(v)) return { ok: false, why: `${f.label} ${v} is not a version this writer was checked against (${f.tested.join(", ")})` }
556
+ if (mustClose(v) && running()) return { ok: false, why: `${app} is running; close it first, since ${usesStateDb(f, v) ? `${f.label} ${v} keeps its task list in ${app}'s state database` : `${app} keeps its own state beside ${f.label}'s tasks`}` }
557
+ if (usesStateDb(f, v) && !exists(stateDb(w.home, host.app))) return { ok: false, why: `${app} has no state database yet (${stateDb(w.home, host.app)}); open ${f.label} in ${app} once first` }
558
+ const custom = f === ROO ? rooCustomStorage(w.home, host.app) : undefined
559
+ if (custom) return { ok: false, why: `${f.label} keeps its tasks in ${custom} (roo-cline.customStoragePath); this writer only writes to ${app}'s own storage` }
560
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; ${f.label} lists a task under the folder it ran in` }
561
+ return { ok: true }
562
+ },
563
+
564
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
565
+ const version = installed(w.home) ?? f.tested.at(-1)!
566
+ const now = new Date()
567
+ const root = storageDir(w.home, host.app, f.extension)
568
+ const tasks = join(root, "tasks")
569
+ let taskId: string
570
+ if (f === ROO) taskId = uuidv7(now.getTime())
571
+ else {
572
+ let n = now.getTime()
573
+ while (exists(join(tasks, String(n)))) n++
574
+ taskId = String(n)
575
+ }
576
+ const dir = join(tasks, taskId)
577
+ const plan = planTask(session, f, { taskId, cwd: w.cwd, now, version })
578
+
579
+ const files: Array<[string, string]> = [
580
+ [join(dir, "api_conversation_history.json"), JSON.stringify(plan.api)],
581
+ [join(dir, "ui_messages.json"), JSON.stringify(plan.ui)],
582
+ ]
583
+ if (plan.metadata) files.push([join(dir, "task_metadata.json"), JSON.stringify(plan.metadata, null, 2)])
584
+ const item: Record<string, unknown> = { ...plan.item, size: files.reduce((n, [, data]) => n + Buffer.byteLength(data), 0) }
585
+ const db = usesStateDb(f, version)
586
+ const fileIndex = f === ROO && !db
587
+ if (fileIndex) files.push([join(dir, "history_item.json"), JSON.stringify(item, null, "\t")])
588
+ const index = db ? stateDb(w.home, host.app) : fileIndex ? join(tasks, "_index.json") : join(root, "state", "taskHistory.json")
589
+ const written = [...files.map(([p]) => p), index]
590
+ const result = (): WriteResult => ({
591
+ tool,
592
+ way: "native",
593
+ sessionId: taskId,
594
+ written,
595
+ open: `${host.cli} ${shellQuote(w.cwd)}`,
596
+ note: `In ${app}, open ${f.label}, click History, and pick "${oneLine(String(item.task)).slice(0, 80)}".`,
597
+ lost: plan.lost,
598
+ dryRun: !!w.dryRun,
599
+ })
600
+ if (w.dryRun) return result()
601
+
602
+ if (mustClose(version) && running()) throw new Error(`${app} is running; close it before continuing a session in ${f.label}`)
603
+ if (exists(dir)) throw new Error(`task folder ${dir} already exists`)
604
+ if (db && !exists(index)) throw new Error(`${app} has no state database yet (${index}); open ${f.label} in ${app} once first`)
605
+ mkdirSync(dir, { recursive: true })
606
+ try {
607
+ // "wx": new files only; an existing task is never touched.
608
+ for (const [p, data] of files) writeFileSync(p, data, { flag: "wx" })
609
+ const back = await taskReader({ tool: f.tool, label: f.label, extension: f.extension }).read({ tool: f.tool, sourceId: taskId, path: dir, extra: { host: host.app } }, w.home)
610
+ const pairs = unpaired(back)
611
+ const got = countTurns(back).tool_call
612
+ if (got !== plan.expect.calls || pairs.calls.length || pairs.results.length)
613
+ throw new Error(`the new ${f.label} task did not read back intact (${got} of ${plan.expect.calls} tool calls, ${pairs.calls.length + pairs.results.length} unpaired)`)
614
+
615
+ const backups = join(w.home, ".brain", "backups", f.tool, stamp(now))
616
+ if (db) {
617
+ // VS Code holds this table in memory while it runs and writes it back; it is closed (checked above).
618
+ written.push(...backupDb(index, backups))
619
+ updateState(index, f.stateKey, (state) => {
620
+ const history = Array.isArray(state.taskHistory) ? (state.taskHistory as Array<Record<string, unknown>>) : []
621
+ if (history.some((h) => h?.id === taskId)) throw new Error(`task ${taskId} is already in ${f.label}'s history`)
622
+ state.taskHistory = [...history, item]
623
+ })
624
+ } else if (fileIndex) {
625
+ // history_item.json is what Roo's store reconciles from; its index is a cache, kept in step when there is one.
626
+ const cache = readJson<{ version?: number; entries?: Array<Record<string, unknown>> }>(index)
627
+ if (cache?.version === 1 && Array.isArray(cache.entries)) {
628
+ written.push(backupFile(index, backups))
629
+ const entries = [...cache.entries.filter((e) => e?.id !== taskId), item].sort((a, b) => Number(b.ts ?? 0) - Number(a.ts ?? 0))
630
+ writeAtomic(index, JSON.stringify({ version: 1, updatedAt: now.getTime(), entries }))
631
+ } else written.pop()
632
+ } else {
633
+ const history = readJson<Array<Record<string, unknown>>>(index)
634
+ if (exists(index) && !Array.isArray(history)) throw new Error(`${index} is not a task list; it was left as it is`)
635
+ if (exists(index)) written.push(backupFile(index, backups))
636
+ mkdirSync(dirname(index), { recursive: true })
637
+ writeAtomic(index, JSON.stringify([...(history ?? []), item]))
638
+ }
639
+ } catch (e) {
640
+ rmSync(dir, { recursive: true, force: true })
641
+ throw e
642
+ }
643
+ return result()
644
+ },
645
+ }
646
+ }
647
+
648
+ function backupFile(path: string, dir: string): string {
649
+ mkdirSync(dir, { recursive: true })
650
+ const copy = join(dir, path.split("/").at(-1)!)
651
+ copyFileSync(path, copy)
652
+ return copy
653
+ }
654
+
655
+ /** One writer per extension and editor: VS Code under the extension's own id, the others suffixed. */
656
+ export const taskWriters: Writer[] = [CLINE, ROO].flatMap((flavor) => ["Code", "Cursor", "Windsurf"].map((app) => taskWriter({ flavor, app })))