free-coding-models 0.3.5 → 0.3.6

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.
@@ -171,6 +171,7 @@ export function createKeyHandler(ctx) {
171
171
  saveAsProfile,
172
172
  setActiveProfile,
173
173
  saveConfig,
174
+ persistApiKeysForProvider,
174
175
  getConfiguredInstallableProviders,
175
176
  getInstallTargetModes,
176
177
  getProviderCatalogModels,
@@ -405,7 +406,7 @@ export function createKeyHandler(ctx) {
405
406
  })
406
407
  setActiveProfile(state.config, name)
407
408
  state.activeProfile = name
408
- saveConfig(state.config)
409
+ saveConfig(state.config, { replaceProfileNames: [name] })
409
410
  }
410
411
  state.profileSaveMode = false
411
412
  state.profileSaveBuffer = ''
@@ -985,14 +986,24 @@ const updateRowIdx = providerKeys.length
985
986
  setTimeout(() => { state.settingsErrorMsg = null }, 3000)
986
987
  return
987
988
  }
989
+ if (!state.config.apiKeys || typeof state.config.apiKeys !== 'object' || Array.isArray(state.config.apiKeys)) {
990
+ state.config.apiKeys = {}
991
+ }
988
992
  if (state.settingsAddKeyMode) {
989
993
  // 📖 Add-key mode: append new key (addApiKey handles duplicates/empty)
990
994
  addApiKey(state.config, pk, newKey)
991
995
  } else {
992
- // 📖 Edit mode: replace the primary key (string-level)
993
- state.config.apiKeys[pk] = newKey
996
+ // 📖 Edit mode: replace only the primary key and keep any extra rotated keys intact.
997
+ const existingKeys = resolveApiKeys(state.config, pk)
998
+ state.config.apiKeys[pk] = existingKeys.length > 1
999
+ ? [newKey, ...existingKeys.slice(1)]
1000
+ : newKey
1001
+ }
1002
+ const saveResult = persistApiKeysForProvider(state.config, pk)
1003
+ if (!saveResult.success) {
1004
+ state.settingsErrorMsg = `⚠️ Failed to persist ${pk} API key: ${saveResult.error || 'Unknown error'}`
1005
+ setTimeout(() => { state.settingsErrorMsg = null }, 4000)
994
1006
  }
995
- saveConfig(state.config)
996
1007
  }
997
1008
  state.settingsEditMode = false
998
1009
  state.settingsAddKeyMode = false
@@ -1103,7 +1114,7 @@ const updateRowIdx = providerKeys.length
1103
1114
  if (state.settingsCursor === widthWarningRowIdx) {
1104
1115
  if (!state.config.settings) state.config.settings = {}
1105
1116
  state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
1106
- saveConfig(state.config)
1117
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1107
1118
  return
1108
1119
  }
1109
1120
 
@@ -1156,7 +1167,10 @@ const updateRowIdx = providerKeys.length
1156
1167
  applyTierFilter()
1157
1168
  const visible = state.results.filter(r => !r.hidden)
1158
1169
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1159
- saveConfig(state.config)
1170
+ saveConfig(state.config, {
1171
+ replaceApiKeys: true,
1172
+ replaceFavorites: true,
1173
+ })
1160
1174
  }
1161
1175
  }
1162
1176
  return
@@ -1164,7 +1178,7 @@ const updateRowIdx = providerKeys.length
1164
1178
 
1165
1179
  // 📖 Enter edit mode for the selected provider's key
1166
1180
  const pk = providerKeys[state.settingsCursor]
1167
- state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
1181
+ state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
1168
1182
  state.settingsEditMode = true
1169
1183
  return
1170
1184
  }
@@ -1176,7 +1190,7 @@ const updateRowIdx = providerKeys.length
1176
1190
  if (state.settingsCursor === widthWarningRowIdx) {
1177
1191
  if (!state.config.settings) state.config.settings = {}
1178
1192
  state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
1179
- saveConfig(state.config)
1193
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1180
1194
  return
1181
1195
  }
1182
1196
  // 📖 Profile rows don't respond to Space
@@ -1187,7 +1201,7 @@ const updateRowIdx = providerKeys.length
1187
1201
  if (!state.config.providers) state.config.providers = {}
1188
1202
  if (!state.config.providers[pk]) state.config.providers[pk] = { enabled: true }
1189
1203
  state.config.providers[pk].enabled = !isProviderEnabled(state.config, pk)
1190
- saveConfig(state.config)
1204
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1191
1205
  return
1192
1206
  }
1193
1207
 
@@ -1218,7 +1232,7 @@ const updateRowIdx = providerKeys.length
1218
1232
  setActiveProfile(state.config, null)
1219
1233
  state.activeProfile = null
1220
1234
  }
1221
- saveConfig(state.config)
1235
+ saveConfig(state.config, { removedProfileNames: [profileName] })
1222
1236
  // 📖 Re-clamp cursor after deletion (profile list just got shorter)
1223
1237
  const newProfiles = listProfiles(state.config)
1224
1238
  const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : changelogViewRowIdx
@@ -1285,7 +1299,11 @@ const updateRowIdx = providerKeys.length
1285
1299
  const pk = providerKeys[state.settingsCursor]
1286
1300
  const removed = removeApiKey(state.config, pk) // removes last key; collapses array-of-1 to string
1287
1301
  if (removed) {
1288
- saveConfig(state.config)
1302
+ const saveResult = persistApiKeysForProvider(state.config, pk)
1303
+ if (!saveResult.success) {
1304
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Failed to save API key changes: ${saveResult.error || 'Unknown error'}` }
1305
+ return
1306
+ }
1289
1307
  const remaining = resolveApiKeys(state.config, pk).length
1290
1308
  const msg = remaining > 0
1291
1309
  ? `✅ Removed one key for ${pk} (${remaining} remaining)`
@@ -1325,7 +1343,7 @@ const updateRowIdx = providerKeys.length
1325
1343
  }
1326
1344
  if (!state.config.settings) state.config.settings = {}
1327
1345
  state.config.settings.proxy = { ...proxySettings, preferredPort: parsed }
1328
- saveConfig(state.config)
1346
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1329
1347
  state.settingsProxyPortEditMode = false
1330
1348
  state.settingsProxyPortBuffer = ''
1331
1349
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Preferred port saved: ${parsed === 0 ? 'auto' : parsed}`, ts: Date.now() }
@@ -1366,7 +1384,7 @@ const updateRowIdx = providerKeys.length
1366
1384
  if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
1367
1385
  if (!state.config.settings) state.config.settings = {}
1368
1386
  state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
1369
- saveConfig(state.config)
1387
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1370
1388
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
1371
1389
  return
1372
1390
  }
@@ -1377,7 +1395,7 @@ const updateRowIdx = providerKeys.length
1377
1395
  }
1378
1396
  if (!state.config.settings) state.config.settings = {}
1379
1397
  state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
1380
- saveConfig(state.config)
1398
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1381
1399
  const { getToolMeta } = await import('./tool-metadata.js')
1382
1400
  const toolLabel = getToolMeta(currentProxyTool).label
1383
1401
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
@@ -1393,7 +1411,7 @@ const updateRowIdx = providerKeys.length
1393
1411
  if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
1394
1412
  if (!state.config.settings) state.config.settings = {}
1395
1413
  state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
1396
- saveConfig(state.config)
1414
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1397
1415
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
1398
1416
  return
1399
1417
  }
@@ -1406,7 +1424,7 @@ const updateRowIdx = providerKeys.length
1406
1424
  }
1407
1425
  if (!state.config.settings) state.config.settings = {}
1408
1426
  state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
1409
- saveConfig(state.config)
1427
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1410
1428
  const { getToolMeta } = await import('./tool-metadata.js')
1411
1429
  const toolLabel = getToolMeta(currentProxyTool).label
1412
1430
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
@@ -1456,7 +1474,7 @@ const updateRowIdx = providerKeys.length
1456
1474
  if (!state.config.settings.proxy.preferredPort || state.config.settings.proxy.preferredPort === 0) {
1457
1475
  state.config.settings.proxy.preferredPort = 18045
1458
1476
  }
1459
- saveConfig(state.config)
1477
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1460
1478
  const result = installDaemon()
1461
1479
  if (result.success) {
1462
1480
  state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service installed and started!', ts: Date.now() }
@@ -1470,7 +1488,7 @@ const updateRowIdx = providerKeys.length
1470
1488
  // 📖 Uninstall daemon
1471
1489
  const result = uninstallDaemon()
1472
1490
  state.config.settings.proxy.daemonEnabled = false
1473
- saveConfig(state.config)
1491
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1474
1492
  if (result.success) {
1475
1493
  state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service uninstalled.', ts: Date.now() }
1476
1494
  state.daemonStatus = 'not-installed'
@@ -1629,7 +1647,7 @@ const updateRowIdx = providerKeys.length
1629
1647
  })
1630
1648
  setActiveProfile(state.config, 'default')
1631
1649
  state.activeProfile = 'default'
1632
- saveConfig(state.config)
1650
+ saveConfig(state.config, { replaceProfileNames: ['default'] })
1633
1651
  } else {
1634
1652
  // 📖 Cycle to next profile (or back to null = raw config)
1635
1653
  const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
@@ -1662,7 +1680,10 @@ const updateRowIdx = providerKeys.length
1662
1680
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1663
1681
  state.cursor = 0
1664
1682
  state.scrollOffset = 0
1665
- saveConfig(state.config)
1683
+ saveConfig(state.config, {
1684
+ replaceApiKeys: true,
1685
+ replaceFavorites: true,
1686
+ })
1666
1687
  }
1667
1688
  }
1668
1689
  }
@@ -1693,7 +1714,7 @@ const updateRowIdx = providerKeys.length
1693
1714
  profile.settings.sortColumn = state.config.settings.sortColumn
1694
1715
  profile.settings.sortAsc = state.config.settings.sortAsc
1695
1716
  }
1696
- saveConfig(state.config)
1717
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1697
1718
  }
1698
1719
 
1699
1720
  // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
@@ -1717,7 +1738,7 @@ const updateRowIdx = providerKeys.length
1717
1738
  delete profile.settings.sortAsc
1718
1739
  }
1719
1740
  }
1720
- saveConfig(state.config)
1741
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1721
1742
  applyTierFilter()
1722
1743
  const visible = state.results.filter(r => !r.hidden)
1723
1744
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
@@ -1824,7 +1845,7 @@ const updateRowIdx = providerKeys.length
1824
1845
  if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1825
1846
  profile.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1826
1847
  }
1827
- saveConfig(state.config)
1848
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1828
1849
  applyTierFilter()
1829
1850
  const visible = state.results.filter(r => !r.hidden)
1830
1851
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
@@ -1891,7 +1912,7 @@ const updateRowIdx = providerKeys.length
1891
1912
  if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1892
1913
  profile.settings.preferredToolMode = state.mode
1893
1914
  }
1894
- saveConfig(state.config)
1915
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1895
1916
  return
1896
1917
  }
1897
1918
 
package/src/testfcm.js ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * @file src/testfcm.js
3
+ * @description Shared helpers for the AI-driven `/testfcm` workflow.
4
+ *
5
+ * @details
6
+ * 📖 These helpers stay side-effect free on purpose so the reporting logic can
7
+ * 📖 be unit-tested without spawning a PTY or touching the user's machine.
8
+ *
9
+ * 📖 The runner in `scripts/testfcm-runner.mjs` handles the live terminal work:
10
+ * 📖 copying config into an isolated HOME, driving the TUI, launching a tool,
11
+ * 📖 sending a prompt, collecting logs, and writing the final Markdown report.
12
+ * 📖 This module focuses on the pieces that should remain stable and reusable:
13
+ * 📖 tool metadata, transcript classification, JSON extraction, and report text.
14
+ *
15
+ * @functions
16
+ * → `normalizeTestfcmToolName` — map aliases like `claude` to canonical FCM tool modes
17
+ * → `resolveTestfcmToolSpec` — return the runner metadata for one tool mode
18
+ * → `hasConfiguredKey` — decide whether a config entry really contains an API key
19
+ * → `createTestfcmRunId` — build a stable timestamp-based run id for artifacts
20
+ * → `extractJsonPayload` — recover JSON mode output even when logs prefix stdout
21
+ * → `detectTranscriptFindings` — map raw tool output to actionable failure findings
22
+ * → `classifyToolTranscript` — classify a run as passed, failed, or inconclusive
23
+ * → `buildFixTasks` — convert findings into concrete follow-up work items
24
+ * → `buildTestfcmReport` — render the final Markdown report written under `task/`
25
+ *
26
+ * @exports TESTFCM_TOOL_SPECS, normalizeTestfcmToolName, resolveTestfcmToolSpec
27
+ * @exports hasConfiguredKey, createTestfcmRunId, extractJsonPayload
28
+ * @exports detectTranscriptFindings, classifyToolTranscript, buildFixTasks
29
+ * @exports buildTestfcmReport
30
+ */
31
+
32
+ export const TESTFCM_TOOL_SPECS = {
33
+ crush: {
34
+ mode: 'crush',
35
+ label: 'Crush',
36
+ command: 'crush',
37
+ flag: '--crush',
38
+ prefersProxy: true,
39
+ configPaths: ['.config/crush/crush.json'],
40
+ },
41
+ codex: {
42
+ mode: 'codex',
43
+ label: 'Codex CLI',
44
+ command: 'codex',
45
+ flag: '--codex',
46
+ prefersProxy: true,
47
+ configPaths: [],
48
+ },
49
+ 'claude-code': {
50
+ mode: 'claude-code',
51
+ label: 'Claude Code',
52
+ command: 'claude',
53
+ flag: '--claude-code',
54
+ prefersProxy: true,
55
+ configPaths: [],
56
+ },
57
+ gemini: {
58
+ mode: 'gemini',
59
+ label: 'Gemini CLI',
60
+ command: 'gemini',
61
+ flag: '--gemini',
62
+ prefersProxy: true,
63
+ configPaths: ['.gemini/settings.json'],
64
+ },
65
+ goose: {
66
+ mode: 'goose',
67
+ label: 'Goose',
68
+ command: 'goose',
69
+ flag: '--goose',
70
+ prefersProxy: true,
71
+ configPaths: ['.config/goose/config.yaml'],
72
+ },
73
+ aider: {
74
+ mode: 'aider',
75
+ label: 'Aider',
76
+ command: 'aider',
77
+ flag: '--aider',
78
+ prefersProxy: false,
79
+ configPaths: ['.aider.conf.yml'],
80
+ },
81
+ qwen: {
82
+ mode: 'qwen',
83
+ label: 'Qwen Code',
84
+ command: 'qwen',
85
+ flag: '--qwen',
86
+ prefersProxy: false,
87
+ configPaths: ['.qwen/settings.json'],
88
+ },
89
+ amp: {
90
+ mode: 'amp',
91
+ label: 'Amp',
92
+ command: 'amp',
93
+ flag: '--amp',
94
+ prefersProxy: false,
95
+ configPaths: ['.config/amp/settings.json'],
96
+ },
97
+ pi: {
98
+ mode: 'pi',
99
+ label: 'Pi',
100
+ command: 'pi',
101
+ flag: '--pi',
102
+ prefersProxy: false,
103
+ configPaths: ['.pi/agent/models.json', '.pi/agent/settings.json'],
104
+ },
105
+ opencode: {
106
+ mode: 'opencode',
107
+ label: 'OpenCode CLI',
108
+ command: 'opencode',
109
+ flag: '--opencode',
110
+ prefersProxy: false,
111
+ configPaths: ['.config/opencode/opencode.json'],
112
+ },
113
+ openhands: {
114
+ mode: 'openhands',
115
+ label: 'OpenHands',
116
+ command: 'openhands',
117
+ flag: '--openhands',
118
+ prefersProxy: false,
119
+ configPaths: [],
120
+ },
121
+ }
122
+
123
+ const TESTFCM_TOOL_ALIASES = {
124
+ claude: 'claude-code',
125
+ claudecode: 'claude-code',
126
+ codexcli: 'codex',
127
+ opencodecli: 'opencode',
128
+ }
129
+
130
+ const TRANSCRIPT_FINDING_RULES = [
131
+ {
132
+ id: 'tool_missing',
133
+ title: 'Tool binary missing',
134
+ severity: 'high',
135
+ regex: /could not find "[^"]+" in path|command not found|enoent/i,
136
+ task: 'Install the requested tool binary or pass `--tool-bin-dir` so FCM can launch it during `/testfcm`.',
137
+ },
138
+ {
139
+ id: 'invalid_api_key',
140
+ title: 'Invalid or missing API auth',
141
+ severity: 'high',
142
+ regex: /invalid api|bad api key|incorrect api key|authentication failed|unauthorized|forbidden|missing api key|no api key|anthropic_auth_token|401\b|403\b/i,
143
+ task: 'Validate the provider key used by the selected model, then re-run `/testfcm` and inspect the proxy request log for the failing request.',
144
+ },
145
+ {
146
+ id: 'rate_limited',
147
+ title: 'Provider rate limited',
148
+ severity: 'medium',
149
+ regex: /rate limit|too many requests|quota exceeded|429\b/i,
150
+ task: 'Retry with another configured provider or inspect the retry-after and cooldown handling in the proxy/tool launch flow.',
151
+ },
152
+ {
153
+ id: 'proxy_failure',
154
+ title: 'Proxy startup or routing failure',
155
+ severity: 'high',
156
+ regex: /failed to start proxy|proxy mode .* required|selected model may not exist|routing reload is taking longer than expected|proxy launch is blocked/i,
157
+ task: 'Inspect `request-log.jsonl`, proxy startup messages, and the isolated config to verify the tool can reach the local FCM proxy.',
158
+ },
159
+ {
160
+ id: 'tool_launch_failed',
161
+ title: 'Tool launch failed',
162
+ severity: 'high',
163
+ regex: /failed to launch|failed to start|process exited with code 1|syntaxerror|traceback|fatal:/i,
164
+ task: 'Inspect the tool transcript and generated tool config under the isolated HOME to find the exact launcher failure.',
165
+ },
166
+ ]
167
+
168
+ const SUCCESS_PATTERNS = [
169
+ /hello[,! ]/i,
170
+ /how can i help/i,
171
+ /how may i help/i,
172
+ /how can i assist/i,
173
+ /ready to help/i,
174
+ ]
175
+
176
+ /**
177
+ * 📖 Normalize a user/tool alias to the canonical FCM tool mode.
178
+ *
179
+ * @param {string | null | undefined} value
180
+ * @returns {string | null}
181
+ */
182
+ export function normalizeTestfcmToolName(value) {
183
+ if (typeof value !== 'string' || value.trim().length === 0) return null
184
+ const normalized = value.trim().toLowerCase()
185
+ return TESTFCM_TOOL_ALIASES[normalized] || normalized
186
+ }
187
+
188
+ /**
189
+ * 📖 Resolve one `/testfcm` tool spec from user input.
190
+ *
191
+ * @param {string | null | undefined} value
192
+ * @returns {typeof TESTFCM_TOOL_SPECS[keyof typeof TESTFCM_TOOL_SPECS] | null}
193
+ */
194
+ export function resolveTestfcmToolSpec(value) {
195
+ const normalized = normalizeTestfcmToolName(value)
196
+ if (!normalized) return null
197
+ return TESTFCM_TOOL_SPECS[normalized] || null
198
+ }
199
+
200
+ /**
201
+ * 📖 Treat both string and multi-key array config entries as "configured" when at
202
+ * 📖 least one non-empty key is present.
203
+ *
204
+ * @param {unknown} value
205
+ * @returns {boolean}
206
+ */
207
+ export function hasConfiguredKey(value) {
208
+ if (typeof value === 'string') return value.trim().length > 0
209
+ if (Array.isArray(value)) return value.some((entry) => typeof entry === 'string' && entry.trim().length > 0)
210
+ return false
211
+ }
212
+
213
+ /**
214
+ * 📖 Build an artifact-friendly run id such as `20260316-184512`.
215
+ *
216
+ * @param {Date} [date]
217
+ * @returns {string}
218
+ */
219
+ export function createTestfcmRunId(date = new Date()) {
220
+ const iso = date.toISOString()
221
+ return iso
222
+ .replace(/\.\d{3}Z$/, '')
223
+ .replace(/:/g, '')
224
+ .replace(/-/g, '')
225
+ .replace('T', '-')
226
+ }
227
+
228
+ /**
229
+ * 📖 Extract the first valid JSON array payload from mixed stdout text.
230
+ *
231
+ * @param {string} text
232
+ * @returns {Array<object> | null}
233
+ */
234
+ export function extractJsonPayload(text) {
235
+ const source = String(text || '')
236
+ let offset = source.indexOf('[')
237
+ while (offset !== -1) {
238
+ const candidate = source.slice(offset).trim()
239
+ try {
240
+ const parsed = JSON.parse(candidate)
241
+ return Array.isArray(parsed) ? parsed : null
242
+ } catch {
243
+ offset = source.indexOf('[', offset + 1)
244
+ }
245
+ }
246
+ return null
247
+ }
248
+
249
+ /**
250
+ * 📖 Detect known failure patterns in the raw tool transcript.
251
+ *
252
+ * @param {string} output
253
+ * @returns {Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>}
254
+ */
255
+ export function detectTranscriptFindings(output) {
256
+ const transcript = String(output || '')
257
+ const findings = []
258
+
259
+ for (const rule of TRANSCRIPT_FINDING_RULES) {
260
+ const match = transcript.match(rule.regex)
261
+ if (!match) continue
262
+
263
+ findings.push({
264
+ id: rule.id,
265
+ title: rule.title,
266
+ severity: rule.severity,
267
+ task: rule.task,
268
+ excerpt: match[0],
269
+ })
270
+ }
271
+
272
+ return findings
273
+ }
274
+
275
+ /**
276
+ * 📖 Decide whether the tool transcript proves success, proves failure, or stays
277
+ * 📖 too ambiguous to trust.
278
+ *
279
+ * @param {string} output
280
+ * @returns {{ status: 'passed' | 'failed' | 'inconclusive', findings: Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>, matchedSuccess: string | null }}
281
+ */
282
+ export function classifyToolTranscript(output) {
283
+ const transcript = String(output || '')
284
+ const matchedSuccess = SUCCESS_PATTERNS.find((pattern) => pattern.test(transcript))
285
+
286
+ if (matchedSuccess) {
287
+ return {
288
+ status: 'passed',
289
+ findings: [],
290
+ matchedSuccess: matchedSuccess.source,
291
+ }
292
+ }
293
+
294
+ const findings = detectTranscriptFindings(transcript)
295
+ if (findings.length > 0) {
296
+ return {
297
+ status: 'failed',
298
+ findings,
299
+ matchedSuccess: null,
300
+ }
301
+ }
302
+
303
+ return {
304
+ status: 'inconclusive',
305
+ findings: [],
306
+ matchedSuccess: null,
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 📖 Collapse findings into unique human-readable follow-up tasks.
312
+ *
313
+ * @param {Array<{ task: string }>} findings
314
+ * @returns {string[]}
315
+ */
316
+ export function buildFixTasks(findings) {
317
+ const tasks = new Set()
318
+ for (const finding of findings) {
319
+ if (typeof finding?.task === 'string' && finding.task.trim().length > 0) {
320
+ tasks.add(finding.task.trim())
321
+ }
322
+ }
323
+ return [...tasks]
324
+ }
325
+
326
+ /**
327
+ * 📖 Render the final Markdown report saved under `task/reports/`.
328
+ *
329
+ * @param {{
330
+ * runId: string,
331
+ * status: 'passed' | 'failed' | 'blocked',
332
+ * startedAt: string,
333
+ * finishedAt: string,
334
+ * toolLabel: string,
335
+ * toolMode: string,
336
+ * prompt: string,
337
+ * configuredProviders: string[],
338
+ * toolBinaryPath: string | null,
339
+ * isolatedHome: string,
340
+ * preflightSummary: string,
341
+ * findings: Array<{ id: string, title: string, severity: string, excerpt: string }>,
342
+ * tasks: string[],
343
+ * evidenceFiles: string[],
344
+ * requestLogSummary: string[],
345
+ * notes: string[],
346
+ * transcriptExcerpt: string
347
+ * }} input
348
+ * @returns {string}
349
+ */
350
+ export function buildTestfcmReport(input) {
351
+ const lines = []
352
+ const findings = Array.isArray(input.findings) ? input.findings : []
353
+ const tasks = Array.isArray(input.tasks) ? input.tasks : []
354
+ const evidenceFiles = Array.isArray(input.evidenceFiles) ? input.evidenceFiles : []
355
+ const requestLogSummary = Array.isArray(input.requestLogSummary) ? input.requestLogSummary : []
356
+ const notes = Array.isArray(input.notes) ? input.notes : []
357
+ const configuredProviders = Array.isArray(input.configuredProviders) ? input.configuredProviders : []
358
+ const transcriptExcerpt = String(input.transcriptExcerpt || '').trim()
359
+
360
+ lines.push(`# /testfcm Report - ${input.runId}`)
361
+ lines.push('')
362
+ lines.push(`- Status: **${input.status.toUpperCase()}**`)
363
+ lines.push(`- Started: ${input.startedAt}`)
364
+ lines.push(`- Finished: ${input.finishedAt}`)
365
+ lines.push(`- Tool: ${input.toolLabel} (${input.toolMode})`)
366
+ lines.push(`- Prompt sent: \`${input.prompt}\``)
367
+ lines.push(`- Configured providers in isolated run: ${configuredProviders.length > 0 ? configuredProviders.join(', ') : '(none)'}`)
368
+ lines.push(`- Tool binary: ${input.toolBinaryPath || '(not found on PATH)'}`)
369
+ lines.push(`- Isolated HOME: \`${input.isolatedHome}\``)
370
+ lines.push('')
371
+ lines.push('## Summary')
372
+ lines.push('')
373
+ lines.push(input.preflightSummary)
374
+ lines.push('')
375
+
376
+ if (findings.length > 0) {
377
+ lines.push('## Bugs Found')
378
+ lines.push('')
379
+ for (const finding of findings) {
380
+ lines.push(`- [${finding.severity}] ${finding.title} - evidence: \`${finding.excerpt}\``)
381
+ }
382
+ lines.push('')
383
+ } else {
384
+ lines.push('## Bugs Found')
385
+ lines.push('')
386
+ lines.push('- No blocking bug pattern matched the captured transcript in this run.')
387
+ lines.push('')
388
+ }
389
+
390
+ lines.push('## Tasks To Resolve')
391
+ lines.push('')
392
+ if (tasks.length > 0) {
393
+ for (const task of tasks) {
394
+ lines.push(`- ${task}`)
395
+ }
396
+ } else {
397
+ lines.push('- No follow-up task was generated from the captured evidence.')
398
+ }
399
+ lines.push('')
400
+
401
+ lines.push('## Evidence')
402
+ lines.push('')
403
+ if (evidenceFiles.length > 0) {
404
+ for (const file of evidenceFiles) {
405
+ lines.push(`- ${file}`)
406
+ }
407
+ } else {
408
+ lines.push('- No artifact file was captured.')
409
+ }
410
+ lines.push('')
411
+
412
+ lines.push('## Request Log Summary')
413
+ lines.push('')
414
+ if (requestLogSummary.length > 0) {
415
+ for (const entry of requestLogSummary) {
416
+ lines.push(`- ${entry}`)
417
+ }
418
+ } else {
419
+ lines.push('- No proxy request log entry was captured for this run.')
420
+ }
421
+ lines.push('')
422
+
423
+ lines.push('## Notes')
424
+ lines.push('')
425
+ if (notes.length > 0) {
426
+ for (const note of notes) {
427
+ lines.push(`- ${note}`)
428
+ }
429
+ } else {
430
+ lines.push('- No extra notes.')
431
+ }
432
+ lines.push('')
433
+
434
+ lines.push('## Transcript Excerpt')
435
+ lines.push('')
436
+ if (transcriptExcerpt) {
437
+ lines.push('```text')
438
+ lines.push(transcriptExcerpt)
439
+ lines.push('```')
440
+ } else {
441
+ lines.push('```text')
442
+ lines.push('(empty transcript excerpt)')
443
+ lines.push('```')
444
+ }
445
+ lines.push('')
446
+ lines.push('## Next Step')
447
+ lines.push('')
448
+ lines.push('Ask the AI to read this report, summarize the blockers, and propose or apply the fixes.')
449
+
450
+ return lines.join('\n')
451
+ }