@manyoars/oars 0.2.0 → 0.4.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 (320) hide show
  1. package/README.md +147 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +10 -4
  5. package/dist/cli.js +440 -91
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.d.ts +1 -1
  9. package/dist/commands/login.js +3 -3
  10. package/dist/errors.d.ts +13 -13
  11. package/dist/errors.js +28 -11
  12. package/dist/gate/checks.d.ts +68 -0
  13. package/dist/gate/checks.js +584 -0
  14. package/dist/gate/command.d.ts +14 -0
  15. package/dist/gate/command.js +61 -0
  16. package/dist/gate/hook-fill-main.d.ts +1 -0
  17. package/dist/gate/hook-fill-main.js +16 -0
  18. package/dist/gate/hook-fill.d.ts +38 -0
  19. package/dist/gate/hook-fill.js +94 -0
  20. package/dist/gate/hook-main.d.ts +1 -0
  21. package/dist/gate/hook-main.js +47 -0
  22. package/dist/gate/hook-process.d.ts +24 -0
  23. package/dist/gate/hook-process.js +112 -0
  24. package/dist/gate/hook-record.d.ts +3 -0
  25. package/dist/gate/hook-record.js +8 -0
  26. package/dist/gate/hook.d.ts +48 -0
  27. package/dist/gate/hook.js +259 -0
  28. package/dist/gate/install.d.ts +25 -0
  29. package/dist/gate/install.js +126 -0
  30. package/dist/gate/local-bin.d.ts +8 -0
  31. package/dist/gate/local-bin.js +36 -0
  32. package/dist/gate/notes.d.ts +19 -0
  33. package/dist/gate/notes.js +27 -0
  34. package/dist/gate/novelty.d.ts +35 -0
  35. package/dist/gate/novelty.js +99 -0
  36. package/dist/gate/policy.d.ts +25 -0
  37. package/dist/gate/policy.js +73 -0
  38. package/dist/gate/record.d.ts +120 -0
  39. package/dist/gate/record.js +125 -0
  40. package/dist/gate/registry-state.d.ts +10 -0
  41. package/dist/gate/registry-state.js +30 -0
  42. package/dist/gate/registry.d.ts +56 -0
  43. package/dist/gate/registry.js +94 -0
  44. package/dist/gate/replay.d.ts +20 -0
  45. package/dist/gate/replay.js +254 -0
  46. package/dist/gate/report.d.ts +50 -0
  47. package/dist/gate/report.js +89 -0
  48. package/dist/gate/scope.d.ts +7 -0
  49. package/dist/gate/scope.js +27 -0
  50. package/dist/gate/transcripts.d.ts +68 -0
  51. package/dist/gate/transcripts.js +232 -0
  52. package/dist/hook-bin.d.ts +1 -0
  53. package/dist/hook-bin.js +50 -0
  54. package/dist/issuer.d.ts +10 -6
  55. package/dist/issuer.js +19 -24
  56. package/dist/main.d.ts +1 -0
  57. package/dist/main.js +67 -0
  58. package/dist/oidc.js +1 -1
  59. package/dist/sessions/actions.d.ts +35 -0
  60. package/dist/sessions/actions.js +429 -0
  61. package/dist/sessions/allow.d.ts +23 -0
  62. package/dist/sessions/allow.js +71 -0
  63. package/dist/sessions/archive.d.ts +11 -0
  64. package/dist/sessions/archive.js +38 -0
  65. package/dist/sessions/audit.d.ts +42 -0
  66. package/dist/sessions/audit.js +104 -0
  67. package/dist/sessions/baseline.d.ts +62 -0
  68. package/dist/sessions/baseline.js +190 -0
  69. package/dist/sessions/call-shape.d.ts +19 -0
  70. package/dist/sessions/call-shape.js +85 -0
  71. package/dist/sessions/capture-runtime.d.ts +10 -0
  72. package/dist/sessions/capture-runtime.js +12 -0
  73. package/dist/sessions/cell-increments.d.ts +47 -0
  74. package/dist/sessions/cell-increments.js +243 -0
  75. package/dist/sessions/cells.d.ts +109 -0
  76. package/dist/sessions/cells.js +261 -0
  77. package/dist/sessions/codex.d.ts +53 -0
  78. package/dist/sessions/codex.js +745 -0
  79. package/dist/sessions/command.d.ts +127 -0
  80. package/dist/sessions/command.js +419 -0
  81. package/dist/sessions/copilot.d.ts +40 -0
  82. package/dist/sessions/copilot.js +432 -0
  83. package/dist/sessions/coverage.d.ts +19 -0
  84. package/dist/sessions/coverage.js +56 -0
  85. package/dist/sessions/cursor.d.ts +24 -0
  86. package/dist/sessions/cursor.js +324 -0
  87. package/dist/sessions/derive.d.ts +57 -0
  88. package/dist/sessions/derive.js +386 -0
  89. package/dist/sessions/exclusion.d.ts +87 -0
  90. package/dist/sessions/exclusion.js +361 -0
  91. package/dist/sessions/extract.d.ts +181 -0
  92. package/dist/sessions/extract.js +1806 -0
  93. package/dist/sessions/fold.d.ts +17 -0
  94. package/dist/sessions/fold.js +71 -0
  95. package/dist/sessions/frame.d.ts +24 -0
  96. package/dist/sessions/frame.js +95 -0
  97. package/dist/sessions/gemini.d.ts +37 -0
  98. package/dist/sessions/gemini.js +613 -0
  99. package/dist/sessions/gold-arms.d.ts +67 -0
  100. package/dist/sessions/gold-arms.js +172 -0
  101. package/dist/sessions/gold.d.ts +197 -0
  102. package/dist/sessions/gold.js +463 -0
  103. package/dist/sessions/graph.d.ts +12 -0
  104. package/dist/sessions/graph.js +34 -0
  105. package/dist/sessions/grok.d.ts +36 -0
  106. package/dist/sessions/grok.js +421 -0
  107. package/dist/sessions/hook-calls.d.ts +3 -0
  108. package/dist/sessions/hook-calls.js +47 -0
  109. package/dist/sessions/hook-index.d.ts +66 -0
  110. package/dist/sessions/hook-index.js +164 -0
  111. package/dist/sessions/hooks.d.ts +17 -0
  112. package/dist/sessions/hooks.js +281 -0
  113. package/dist/sessions/identity.d.ts +27 -0
  114. package/dist/sessions/identity.js +131 -0
  115. package/dist/sessions/install.d.ts +70 -0
  116. package/dist/sessions/install.js +246 -0
  117. package/dist/sessions/lineage.d.ts +19 -0
  118. package/dist/sessions/lineage.js +118 -0
  119. package/dist/sessions/mcp.d.ts +7 -0
  120. package/dist/sessions/mcp.js +157 -0
  121. package/dist/sessions/merkle.d.ts +7 -0
  122. package/dist/sessions/merkle.js +28 -0
  123. package/dist/sessions/motif.d.ts +44 -0
  124. package/dist/sessions/motif.js +59 -0
  125. package/dist/sessions/opencode.d.ts +33 -0
  126. package/dist/sessions/opencode.js +319 -0
  127. package/dist/sessions/otel.d.ts +60 -0
  128. package/dist/sessions/otel.js +494 -0
  129. package/dist/sessions/package-books.d.ts +48 -0
  130. package/dist/sessions/package-books.js +120 -0
  131. package/dist/sessions/package-resolve.d.ts +32 -0
  132. package/dist/sessions/package-resolve.js +185 -0
  133. package/dist/sessions/pending.d.ts +34 -0
  134. package/dist/sessions/pending.js +142 -0
  135. package/dist/sessions/pi.d.ts +36 -0
  136. package/dist/sessions/pi.js +353 -0
  137. package/dist/sessions/pool-books.d.ts +21 -0
  138. package/dist/sessions/pool-books.js +75 -0
  139. package/dist/sessions/pool-choices.d.ts +5 -0
  140. package/dist/sessions/pool-choices.js +11 -0
  141. package/dist/sessions/pool-command.d.ts +115 -0
  142. package/dist/sessions/pool-command.js +669 -0
  143. package/dist/sessions/pool-delta.d.ts +54 -0
  144. package/dist/sessions/pool-delta.js +190 -0
  145. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  146. package/dist/sessions/pool-nightly-deps.js +11 -0
  147. package/dist/sessions/pool-nightly.d.ts +10 -0
  148. package/dist/sessions/pool-nightly.js +85 -0
  149. package/dist/sessions/pool-outbox.d.ts +76 -0
  150. package/dist/sessions/pool-outbox.js +313 -0
  151. package/dist/sessions/pool-print.d.ts +26 -0
  152. package/dist/sessions/pool-print.js +68 -0
  153. package/dist/sessions/pool-raw.d.ts +27 -0
  154. package/dist/sessions/pool-raw.js +68 -0
  155. package/dist/sessions/pool-replica.d.ts +35 -0
  156. package/dist/sessions/pool-replica.js +91 -0
  157. package/dist/sessions/pool-send-lock.d.ts +20 -0
  158. package/dist/sessions/pool-send-lock.js +87 -0
  159. package/dist/sessions/pool-sent.d.ts +4 -0
  160. package/dist/sessions/pool-sent.js +26 -0
  161. package/dist/sessions/pool-totals.d.ts +10 -0
  162. package/dist/sessions/pool-totals.js +21 -0
  163. package/dist/sessions/pool-wire.d.ts +435 -0
  164. package/dist/sessions/pool-wire.js +1100 -0
  165. package/dist/sessions/pool.d.ts +32 -0
  166. package/dist/sessions/pool.js +164 -0
  167. package/dist/sessions/projection.d.ts +75 -0
  168. package/dist/sessions/projection.js +165 -0
  169. package/dist/sessions/public-vocabulary.d.ts +72 -0
  170. package/dist/sessions/public-vocabulary.js +295 -0
  171. package/dist/sessions/query.d.ts +123 -0
  172. package/dist/sessions/query.js +268 -0
  173. package/dist/sessions/read-adoption.d.ts +2 -0
  174. package/dist/sessions/read-adoption.js +27 -0
  175. package/dist/sessions/read-call-shape.d.ts +2 -0
  176. package/dist/sessions/read-call-shape.js +25 -0
  177. package/dist/sessions/read-change.d.ts +2 -0
  178. package/dist/sessions/read-change.js +30 -0
  179. package/dist/sessions/read-cost.d.ts +4 -0
  180. package/dist/sessions/read-cost.js +48 -0
  181. package/dist/sessions/read-day-move.d.ts +3 -0
  182. package/dist/sessions/read-day-move.js +65 -0
  183. package/dist/sessions/read-fit.d.ts +7 -0
  184. package/dist/sessions/read-fit.js +41 -0
  185. package/dist/sessions/read-interaction.d.ts +5 -0
  186. package/dist/sessions/read-interaction.js +62 -0
  187. package/dist/sessions/read-lead-lag.d.ts +15 -0
  188. package/dist/sessions/read-lead-lag.js +81 -0
  189. package/dist/sessions/read-live.d.ts +6 -0
  190. package/dist/sessions/read-live.js +38 -0
  191. package/dist/sessions/read-method.d.ts +2 -0
  192. package/dist/sessions/read-method.js +29 -0
  193. package/dist/sessions/read-next-step.d.ts +2 -0
  194. package/dist/sessions/read-next-step.js +35 -0
  195. package/dist/sessions/read-order.d.ts +54 -0
  196. package/dist/sessions/read-order.js +62 -0
  197. package/dist/sessions/read-readable.d.ts +8 -0
  198. package/dist/sessions/read-readable.js +24 -0
  199. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  200. package/dist/sessions/read-recovery-edge.js +26 -0
  201. package/dist/sessions/read-recovery.d.ts +20 -0
  202. package/dist/sessions/read-recovery.js +97 -0
  203. package/dist/sessions/read-rows.d.ts +13 -0
  204. package/dist/sessions/read-rows.js +47 -0
  205. package/dist/sessions/read-substitution.d.ts +5 -0
  206. package/dist/sessions/read-substitution.js +51 -0
  207. package/dist/sessions/read-support.d.ts +48 -0
  208. package/dist/sessions/read-support.js +73 -0
  209. package/dist/sessions/read-tool-day.d.ts +2 -0
  210. package/dist/sessions/read-tool-day.js +26 -0
  211. package/dist/sessions/read-version-boundary.d.ts +31 -0
  212. package/dist/sessions/read-version-boundary.js +95 -0
  213. package/dist/sessions/read.d.ts +86 -0
  214. package/dist/sessions/read.js +87 -0
  215. package/dist/sessions/recall-bundle.d.ts +12 -0
  216. package/dist/sessions/recall-bundle.js +33 -0
  217. package/dist/sessions/recall-consequence.d.ts +45 -0
  218. package/dist/sessions/recall-consequence.js +179 -0
  219. package/dist/sessions/recall-frames.d.ts +18 -0
  220. package/dist/sessions/recall-frames.js +44 -0
  221. package/dist/sessions/recall-next.d.ts +145 -0
  222. package/dist/sessions/recall-next.js +241 -0
  223. package/dist/sessions/recall-object.d.ts +139 -0
  224. package/dist/sessions/recall-object.js +78 -0
  225. package/dist/sessions/recall-pool.d.ts +19 -0
  226. package/dist/sessions/recall-pool.js +96 -0
  227. package/dist/sessions/recall-push.d.ts +26 -0
  228. package/dist/sessions/recall-push.js +47 -0
  229. package/dist/sessions/recall-reads.d.ts +124 -0
  230. package/dist/sessions/recall-reads.js +417 -0
  231. package/dist/sessions/recall-render.d.ts +19 -0
  232. package/dist/sessions/recall-render.js +168 -0
  233. package/dist/sessions/recovery-calls.d.ts +21 -0
  234. package/dist/sessions/recovery-calls.js +25 -0
  235. package/dist/sessions/redact.d.ts +17 -0
  236. package/dist/sessions/redact.js +113 -0
  237. package/dist/sessions/repo-root.d.ts +10 -0
  238. package/dist/sessions/repo-root.js +101 -0
  239. package/dist/sessions/schema.d.ts +694 -0
  240. package/dist/sessions/schema.js +167 -0
  241. package/dist/sessions/session-shape.d.ts +76 -0
  242. package/dist/sessions/session-shape.js +355 -0
  243. package/dist/sessions/sources.d.ts +50 -0
  244. package/dist/sessions/sources.js +233 -0
  245. package/dist/sessions/steps.d.ts +105 -0
  246. package/dist/sessions/steps.js +250 -0
  247. package/dist/sessions/store.d.ts +309 -0
  248. package/dist/sessions/store.js +1498 -0
  249. package/dist/sessions/strata.d.ts +26 -0
  250. package/dist/sessions/strata.js +77 -0
  251. package/dist/sessions/survival.d.ts +27 -0
  252. package/dist/sessions/survival.js +230 -0
  253. package/dist/sessions/transcript-tail.d.ts +14 -0
  254. package/dist/sessions/transcript-tail.js +64 -0
  255. package/dist/sessions/verify.d.ts +53 -0
  256. package/dist/sessions/verify.js +286 -0
  257. package/dist/sessions/vocab-load.d.ts +26 -0
  258. package/dist/sessions/vocab-load.js +61 -0
  259. package/dist/sessions/vscode-copilot.d.ts +38 -0
  260. package/dist/sessions/vscode-copilot.js +494 -0
  261. package/dist/telemetry/command.d.ts +8 -0
  262. package/dist/telemetry/command.js +85 -0
  263. package/dist/telemetry/consent.d.ts +17 -0
  264. package/dist/telemetry/consent.js +63 -0
  265. package/dist/telemetry/crash.d.ts +8 -0
  266. package/dist/telemetry/crash.js +52 -0
  267. package/dist/telemetry/emit.d.ts +31 -0
  268. package/dist/telemetry/emit.js +42 -0
  269. package/dist/telemetry/fields.d.ts +16 -0
  270. package/dist/telemetry/fields.js +26 -0
  271. package/dist/telemetry/health.d.ts +21 -0
  272. package/dist/telemetry/health.js +108 -0
  273. package/dist/telemetry/hook-emit.d.ts +4 -0
  274. package/dist/telemetry/hook-emit.js +18 -0
  275. package/dist/telemetry/measures.d.ts +32 -0
  276. package/dist/telemetry/measures.js +128 -0
  277. package/dist/telemetry/observe.d.ts +13 -0
  278. package/dist/telemetry/observe.js +51 -0
  279. package/dist/telemetry/read-events.d.ts +9 -0
  280. package/dist/telemetry/read-events.js +17 -0
  281. package/dist/telemetry/record.d.ts +17 -0
  282. package/dist/telemetry/record.js +26 -0
  283. package/dist/telemetry/report.d.ts +77 -0
  284. package/dist/telemetry/report.js +188 -0
  285. package/dist/telemetry/schema.d.ts +40 -0
  286. package/dist/telemetry/schema.js +187 -0
  287. package/dist/telemetry/send-main.d.ts +1 -0
  288. package/dist/telemetry/send-main.js +11 -0
  289. package/dist/telemetry/send.d.ts +36 -0
  290. package/dist/telemetry/send.js +172 -0
  291. package/dist/telemetry/spool.d.ts +17 -0
  292. package/dist/telemetry/spool.js +70 -0
  293. package/dist/telemetry/wire.d.ts +25 -0
  294. package/dist/telemetry/wire.js +76 -0
  295. package/package.json +4 -5
  296. package/vocab/crates.json +1 -0
  297. package/vocab/go.json +1 -0
  298. package/vocab/harness.json +1 -0
  299. package/vocab/homebrew.json +1 -0
  300. package/vocab/manifest.json +1 -0
  301. package/vocab/maven.json +1 -0
  302. package/vocab/models.json +1 -0
  303. package/vocab/npm.json +1 -0
  304. package/vocab/programs.json +1 -0
  305. package/vocab/pypi.json +1 -0
  306. package/vocab/rubygems.json +1 -0
  307. package/dist/commands/deploy-input.d.ts +0 -38
  308. package/dist/commands/deploy-input.js +0 -150
  309. package/dist/commands/deploy.d.ts +0 -22
  310. package/dist/commands/deploy.js +0 -124
  311. package/dist/commands/status.d.ts +0 -7
  312. package/dist/commands/status.js +0 -18
  313. package/dist/commands/templates.d.ts +0 -11
  314. package/dist/commands/templates.js +0 -32
  315. package/dist/commands/test-charge.d.ts +0 -11
  316. package/dist/commands/test-charge.js +0 -34
  317. package/dist/journal.d.ts +0 -26
  318. package/dist/journal.js +0 -58
  319. package/parity/deploy-plans/paid-api.json +0 -34
  320. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,1806 @@
1
+ // Capture: turns one transcript's text into its capture records and a skeleton in a single pass (derivation is `derive.ts`). The
2
+ // parsing rules are ported from the reference scripts in docs/research/2026-11-01-session-commons/ (parse_sessions.py, error_signatures.py, prompt_graphs.py,
3
+ // cost_per_class.py) so the counts agree: same normalization and class rule (prompt boundaries differ from the scripts: see `promptOpening`). Where two scripts
4
+ // read a thing differently the comment says which rule is used.
5
+ import { createHash } from 'node:crypto';
6
+ import { posix } from 'node:path';
7
+ import { ACTION_TABLES, actionOf, shellOf } from './actions.js';
8
+ import { exploreCount, strataOf } from './strata.js';
9
+ import { closedFrame, frameIdOf, repoFilesFromIndex, repoSizeOf, stackOf, utcDay } from './frame.js';
10
+ import { argClassOf, targetClassOf } from './call-shape.js';
11
+ import { LITERAL } from './public-vocabulary.js';
12
+ import { linkSteps } from './graph.js';
13
+ import { callIdentity, shellIdentity, UNREAD_SHELL } from './identity.js';
14
+ import { manifestDelta, manifestFileOf, manifestNames, packagesOf } from './package-resolve.js';
15
+ import { createShape, DECLINED_RE, INTERRUPT_MARKER } from './session-shape.js';
16
+ import { CAPTURE_VERSION, ERROR_LINE_CHARS, FILES_PER_SESSION_MAX, HTTP_STATUSES, PROMPT_HEAD_CHARS, SIGNATURE_CHARS, USAGE_FIELDS, } from './schema.js';
17
+ export const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ const str = (value) => (typeof value === 'string' ? value : undefined);
19
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
20
+ /** Python slices by code point; JavaScript by UTF-16 unit. */
21
+ export const cut = (text, length) => Array.from(text).slice(0, length).join('');
22
+ /** Python truthiness of a JSON value. */
23
+ const truthy = (value) => value !== undefined && value !== null && value !== false && value !== 0 && value !== '';
24
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
25
+ export const EXPLORE = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'ToolSearch', 'LSP']);
26
+ export const ACT = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']);
27
+ const COORD = new Set(['Agent', 'SendMessage', 'Monitor', 'TaskStop', 'Skill', 'ScheduleWakeup', 'ListAgents', 'Workflow']);
28
+ export const EDITS = new Set(['Edit', 'Write', 'MultiEdit']);
29
+ export const SHELL = new Set(['Bash', 'PowerShell']);
30
+ const PATH_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
31
+ const TLDR_RE = /^\s*\**\s*(?:TL;?DR|TLDR)\s*:?\**\s*:?\s*(.+?)\s*$/gim;
32
+ const CONF_RE = /^\s*\**\s*Confidence\s*:?\**\s*:?\s*(.+?)\s*$/gim;
33
+ const EXIT_RE = /^Exit code (\d+)/;
34
+ // error_signatures.py NORM, in order. Divergence: JS `\d`, `\s`, `\b`, and `trim()` are ASCII/ECMAScript-defined while Python's `re` and
35
+ // `str.strip()` are Unicode-aware, so a signature with non-ASCII digits or separators normalizes differently from the script.
36
+ // The reference corpus has none; the divergence is accepted, not matched.
37
+ const NORM = [
38
+ [/[A-Za-z]:\\[^\s'"]+|\/[^\s'"]+/g, '<path>'],
39
+ [/\b[0-9a-f]{7,}\b/g, '<hex>'],
40
+ [/\d+/g, '<n>'],
41
+ [/'[^']*'|"[^"]*"/g, '<q>'],
42
+ [/\s+/g, ' '],
43
+ ];
44
+ /** The normalized form of one line: paths, hex, numbers, and quoted strings replaced, cut at 110 characters. */
45
+ export const normalize = (line) => {
46
+ let out = line.trim();
47
+ for (const [pattern, replacement] of NORM)
48
+ out = out.replace(pattern, replacement);
49
+ return cut(out, SIGNATURE_CHARS);
50
+ };
51
+ const LINE_BREAK = new RegExp(['\\r\\n', '[\\n\\r\\v\\f\\x1c-\\x1e\\x85\\u2028\\u2029]'].join('|'));
52
+ const splitLines = (text) => {
53
+ const lines = text.split(LINE_BREAK);
54
+ if (lines.length > 0 && lines[lines.length - 1] === '')
55
+ lines.pop();
56
+ return lines;
57
+ };
58
+ /** The first informative line of a tool result: skips blanks, markup lines, "Only you see", and the `Exit code` line. */
59
+ export const firstInformative = (text) => {
60
+ const lines = splitLines(text);
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (trimmed === '' || trimmed.startsWith('<') || trimmed.toLowerCase().startsWith('only you see'))
64
+ continue;
65
+ if (EXIT_RE.test(trimmed))
66
+ continue;
67
+ return trimmed;
68
+ }
69
+ const whole = text.trim();
70
+ return whole === '' ? '' : (splitLines(whole)[0] ?? '');
71
+ };
72
+ /**
73
+ * The key an error is matched on, by deposit and by recall alike: redact, take the first informative line, normalize, redact again. Redacting
74
+ * first keeps a secret or an email whole before normalize turns its digits into `<n>`; the second pass catches what only the cut form shows.
75
+ */
76
+ export const errorKey = (text, redact) => redact(normalize(redact(firstInformative(text))));
77
+ /** The signature of an error: `<tool> | <errorKey>`. */
78
+ export const signatureOf = (tool, text, redact) => `${tool} | ${errorKey(text, redact)}`;
79
+ export const resultText = (block) => {
80
+ const content = block.content;
81
+ if (typeof content === 'string')
82
+ return content;
83
+ if (Array.isArray(content)) {
84
+ return content
85
+ .filter((part) => isObj(part) && part.type === 'text')
86
+ .map((part) => str(part.text) ?? '')
87
+ .join('\n');
88
+ }
89
+ return '';
90
+ };
91
+ export const isErrorResult = (block, text) => {
92
+ if (truthy(block.is_error))
93
+ return true;
94
+ const exit = EXIT_RE.exec(text.trim());
95
+ return exit !== null && exit[1] !== '0';
96
+ };
97
+ /** What an Edit, MultiEdit, or Write shows of a manifest: the names it adds and removes (an edit), or lists (a write). Undefined when it shows none. */
98
+ const manifestOfInput = (file, tool, input, redact) => {
99
+ if (tool === 'Write') {
100
+ const seen = manifestNames(file, str(input.content) ?? '', redact);
101
+ return seen.length === 0 ? undefined : { file, seen, added: [], removed: [] };
102
+ }
103
+ const edits = tool === 'MultiEdit' && Array.isArray(input.edits) ? input.edits.filter(isObj) : tool === 'Edit' ? [input] : [];
104
+ const { added, removed } = manifestDelta(file, edits.map((edit) => str(edit.old_string) ?? '').join('\n'), edits.map((edit) => str(edit.new_string) ?? '').join('\n'), redact);
105
+ return added.length === 0 && removed.length === 0 ? undefined : { file, seen: [], added, removed };
106
+ };
107
+ const EXIT_CODE_RE = /^Exit code:? (-?\d+)/;
108
+ /** The harness's own refusals, most specific first: each names a class, and the match never leaves this table (the text is not kept). */
109
+ const HARNESS_ERROR_PATTERNS = [
110
+ [/^\[Request interrupted by user/, 'interrupted'],
111
+ // Claude Code's text, then the refusal sentences Codex (`rejected by user`, written by its adapter and by events.rs:456) and Gemini (an abort `User cancelled tool execution`; a confirmation-prompt denial `Reason: User denied execution.`, `User cancelled operation`, or `Operation cancelled by user`) write.
112
+ // The Codex and Gemini alternatives are `DECLINED_RE`'s own source, so the error code and the `user-stop` bucket read one pattern.
113
+ [new RegExp(`The user doesn't want to proceed with this tool use|The tool use was rejected|${DECLINED_RE.source}`), 'user-rejected'],
114
+ [/Permission to use [^\n]* has been denied/, 'permission-denied'],
115
+ [/InputValidationError/, 'input-validation'],
116
+ [/<tool_use_error>/, 'tool-use-error'],
117
+ [/\btimed out\b/i, 'timeout'],
118
+ ];
119
+ const HTTP_STATUS_RE = /(?:status(?: code)?|HTTP\/?[\d.]*|returned error:?)\s*:?\s*([1-5]\d\d)\b/i;
120
+ const exitClass = (code) => {
121
+ if (code === 0 || code === 1 || code === 2 || code === 126 || code === 127)
122
+ return String(code);
123
+ // 128 + n for the signals n = 1 to 64 a shell reports; 255 is the usual spawn or ssh failure and stays `other`.
124
+ return code > 128 && code <= 192 ? 'signal' : 'other';
125
+ };
126
+ /**
127
+ * The `errorCode` of a failed result (spec Section 1.2): an exit-code class from a leading `Exit code N` line, else the harness error class its text
128
+ * names, else an HTTP status, else `other`. The result is always a member of `ERROR_CODES`, picked by a table, so no word of the error text is kept. A
129
+ * harness that reports no exit code (a Cursor non-shell call, a Codex call with no status) reaches `other` unless its text names a class.
130
+ */
131
+ export const errorCodeOf = (text) => {
132
+ const exit = EXIT_CODE_RE.exec(text.trim());
133
+ if (exit !== null)
134
+ return exitClass(Number(exit[1]));
135
+ const harness = HARNESS_ERROR_PATTERNS.find(([pattern]) => pattern.test(text));
136
+ if (harness !== undefined)
137
+ return harness[1];
138
+ const status = HTTP_STATUS_RE.exec(text)?.[1];
139
+ return HTTP_STATUSES.find((listed) => listed === status) ?? 'other';
140
+ };
141
+ export const shortName = (name) => {
142
+ if (name.startsWith('mcp__claude-in-chrome__'))
143
+ return 'chrome';
144
+ if (name.startsWith('mcp__'))
145
+ return `mcp:${(name.split('__')[1] ?? '').slice(0, 10)}`;
146
+ return name;
147
+ };
148
+ export const compress = (sequence) => {
149
+ const runs = [];
150
+ for (const tool of sequence) {
151
+ const last = runs[runs.length - 1];
152
+ if (last?.[0] === tool)
153
+ last[1] += 1;
154
+ else
155
+ runs.push([tool, 1]);
156
+ }
157
+ return runs.map(([tool, count]) => (count > 1 ? `${tool}x${String(count)}` : tool)).join('>');
158
+ };
159
+ /** The prompt class rule shared by prompt_graphs.py and cost_per_class.py. */
160
+ export const classify = (sequence) => {
161
+ if (sequence.length === 0)
162
+ return 'no-tools';
163
+ const set = new Set(sequence);
164
+ const web = sequence.filter((tool) => tool === 'WebFetch' || tool === 'WebSearch').length;
165
+ const coord = sequence.filter((tool) => COORD.has(tool)).length;
166
+ if (coord >= 2)
167
+ return 'orchestration';
168
+ if (web >= 2)
169
+ return 'research';
170
+ if ([...set].some((tool) => EDITS.has(tool))) {
171
+ let seen = false;
172
+ for (const tool of sequence) {
173
+ if (EDITS.has(tool))
174
+ seen = true;
175
+ else if (seen && SHELL.has(tool))
176
+ return 'edit-verify';
177
+ }
178
+ return 'edit-only';
179
+ }
180
+ if ([...set].every((tool) => EXPLORE.has(tool)))
181
+ return 'explore-only';
182
+ if ([...set].every((tool) => SHELL.has(tool)))
183
+ return 'shell-only';
184
+ return 'mixed';
185
+ };
186
+ export const emptyUsage = () => ({ input: 0, cacheWrite: 0, cacheRead: 0, output: 0, thinking: 0 });
187
+ /** Register A22: one API response spans several assistant lines that repeat its usage, so each response counts once, keyed by request id then message id. */
188
+ const responseKey = (line, message) => (str(line.requestId) || undefined) ?? (str(message.id) || undefined);
189
+ const addUsage = (total, line) => {
190
+ total.input += line.input;
191
+ total.cacheWrite += line.cacheWrite;
192
+ total.cacheRead += line.cacheRead;
193
+ total.output += line.output;
194
+ total.thinking += line.thinking;
195
+ };
196
+ const emptyCoverage = () => ({ observed: new Set(), responses: 0 });
197
+ const addCoverage = (coverage, line) => {
198
+ coverage.responses += 1;
199
+ for (const field of line.observed)
200
+ coverage.observed.add(field);
201
+ };
202
+ /** The record fields for a total: the observed categories in the fixed order, and the responses that reported. */
203
+ const coverageFields = (coverage) => ({
204
+ usageObserved: USAGE_FIELDS.filter((field) => coverage.observed.has(field)),
205
+ usageResponses: coverage.responses,
206
+ });
207
+ /** cost_per_class.py: only numeric fields count; thinking comes from `output_tokens_details`. */
208
+ const readUsage = (message) => {
209
+ const raw = message.usage;
210
+ if (!isObj(raw))
211
+ return undefined;
212
+ const details = isObj(raw.output_tokens_details) ? raw.output_tokens_details : {};
213
+ const thinking = num(details.thinking_tokens);
214
+ const input = num(raw.input_tokens);
215
+ const cacheWrite = num(raw.cache_creation_input_tokens);
216
+ const cacheRead = num(raw.cache_read_input_tokens);
217
+ const output = num(raw.output_tokens);
218
+ const reported = [['input', input], ['cacheWrite', cacheWrite], ['cacheRead', cacheRead], ['output', output], ['thinking', thinking]];
219
+ return {
220
+ usage: { input: input ?? 0, cacheWrite: cacheWrite ?? 0, cacheRead: cacheRead ?? 0, output: output ?? 0, thinking: thinking ?? 0 },
221
+ hasThinking: thinking !== undefined,
222
+ observed: reported.filter(([, value]) => value !== undefined).map(([field]) => field),
223
+ };
224
+ };
225
+ const toSeconds = (timestamp) => {
226
+ if (timestamp === undefined)
227
+ return undefined;
228
+ const millis = Date.parse(timestamp);
229
+ return Number.isNaN(millis) ? undefined : millis / 1000;
230
+ };
231
+ /** A `cost-state` line's `startTime` (epoch milliseconds) as an ISO timestamp. */
232
+ const isoOfMillis = (value) => {
233
+ const millis = num(value);
234
+ return millis === undefined || !Number.isFinite(new Date(millis).getTime()) ? null : new Date(millis).toISOString();
235
+ };
236
+ const sumModelUsage = (value, startTime) => {
237
+ if (!isObj(value))
238
+ return null;
239
+ const names = [
240
+ ['input', 'inputTokens', 'input_tokens'],
241
+ ['cacheWrite', 'cacheCreationInputTokens', 'cache_creation_input_tokens'],
242
+ ['cacheRead', 'cacheReadInputTokens', 'cache_read_input_tokens'],
243
+ ['output', 'outputTokens', 'output_tokens'],
244
+ ];
245
+ const totals = { since: isoOfMillis(startTime), input: null, cacheWrite: null, cacheRead: null, output: null, models: [] };
246
+ for (const model of Object.keys(value).sort()) {
247
+ const entry = value[model];
248
+ const keys = [];
249
+ for (const [key, ...aliases] of names) {
250
+ for (const alias of aliases) {
251
+ const amount = isObj(entry) ? num(entry[alias]) : undefined;
252
+ if (amount === undefined)
253
+ continue;
254
+ totals[key] = (totals[key] ?? 0) + amount;
255
+ if (amount > 0 && !keys.includes(key))
256
+ keys.push(key);
257
+ }
258
+ }
259
+ totals.models.push({ model, keys });
260
+ }
261
+ return [totals.input, totals.cacheWrite, totals.cacheRead, totals.output].some((total) => total !== null) ? totals : null;
262
+ };
263
+ const merkleRoot = (leaves) => {
264
+ if (leaves.length === 0)
265
+ return sha256('');
266
+ let level = [...leaves];
267
+ while (level.length > 1) {
268
+ const next = [];
269
+ for (let index = 0; index < level.length; index += 2) {
270
+ const left = level[index] ?? '';
271
+ next.push(sha256(left + (level[index + 1] ?? left)));
272
+ }
273
+ level = next;
274
+ }
275
+ return level[0] ?? sha256('');
276
+ };
277
+ const contentHashes = (content) => {
278
+ if (typeof content === 'string')
279
+ return [sha256(content)];
280
+ if (!Array.isArray(content))
281
+ return [];
282
+ return content.map((block) => {
283
+ if (isObj(block))
284
+ return sha256(str(block.text) ?? str(block.thinking) ?? JSON.stringify(block));
285
+ return sha256(JSON.stringify(block));
286
+ });
287
+ };
288
+ const hasToolResult = (content) => Array.isArray(content) && content.some((block) => isObj(block) && block.type === 'tool_result');
289
+ const hasImage = (content) => Array.isArray(content) && content.some((block) => isObj(block) && block.type === 'image');
290
+ // The tags a local command (a slash command the harness ran itself) leaves as user lines. A model's answer to a command makes it a prompt, and that case never reaches
291
+ // the run: a tag listed here that is still on a line there was not answered.
292
+ const LOCAL_COMMAND_TAGS = new Set(['command-name', 'command-message', 'local-command-stdout', 'local-command-stderr', 'local-command-caveat']);
293
+ const firstTextBlock = (content) => {
294
+ if (typeof content === 'string')
295
+ return content;
296
+ if (!Array.isArray(content))
297
+ return '';
298
+ for (const block of content)
299
+ if (isObj(block) && block.type === 'text')
300
+ return str(block.text) ?? '';
301
+ return '';
302
+ };
303
+ // What the harness writes as a user line and the operator did not ask. A full re-capture checked this: the only leading tag left on a `typed` row is
304
+ // the paste wrapper. `local-command-stderr` is here as the sibling of `local-command-stdout`. The set is closed on purpose: anything else with a leading `<` was
305
+ // typed, or pasted. A `teammate-message` is another agent speaking to a teammate, so a teammate's turns after one stay in its prompt before, or outside any.
306
+ export const INJECTED_TAGS = new Set([
307
+ 'bash-input',
308
+ 'bash-stderr',
309
+ 'bash-stdout',
310
+ 'local-command-caveat',
311
+ 'local-command-stderr',
312
+ 'local-command-stdout',
313
+ 'scheduled-task',
314
+ 'system-reminder',
315
+ 'task-notification',
316
+ 'teammate-message',
317
+ ]);
318
+ export const LEADING_TAG = /^<([a-zA-Z][\w:-]*)[\s>/]/;
319
+ /** What the operator asked, with the wrapper removed: the command's name and its arguments, as they typed them. Either element can come first. */
320
+ const commandAsked = (text) => {
321
+ const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text)?.[1] ?? '';
322
+ const args = /<command-args>([\s\S]*)<\/command-args>/.exec(text)?.[1] ?? '';
323
+ return `${name} ${args}`.trim();
324
+ };
325
+ /**
326
+ * Whether a model answers a command: the next main-line thing after it is an assistant turn. A meta line (a skill's body, a caveat), a tool result, and an
327
+ * attachment do not decide it. A later user line does: it opens that turn itself, and when it is a local command's output the command was local. A synthetic
328
+ * line is the harness speaking, so `/clear` followed by one has still had no turn.
329
+ */
330
+ const answeredByModel = (lines, from, subagent) => {
331
+ for (let at = from; at < lines.length; at += 1) {
332
+ let line;
333
+ try {
334
+ line = JSON.parse(lines[at] ?? '');
335
+ }
336
+ catch {
337
+ continue;
338
+ }
339
+ if (!isObj(line) || !isObj(line.message) || (!subagent && truthy(line.isSidechain)))
340
+ continue;
341
+ const { content, model, role } = line.message;
342
+ if (line.type === 'assistant' && role === 'assistant' && model !== '<synthetic>')
343
+ return true;
344
+ if (line.type !== 'user' || role !== 'user' || line.isMeta === true)
345
+ continue;
346
+ if (firstTextBlock(content) !== '' && !hasToolResult(content))
347
+ return false;
348
+ }
349
+ return false;
350
+ };
351
+ /**
352
+ * Whether a main-line user line with text and no tool result opens a prompt in a Claude Code transcript, and how. Capture and the gold reader both number
353
+ * prompts by this, so a gold entry's index is a capture index. `lines` is the whole transcript and `position` this line's place in it.
354
+ */
355
+ export const promptOpening = (text, lines, position, subagent = false) => {
356
+ const leading = LEADING_TAG.exec(text.trimStart())?.[1];
357
+ // A tag name with no closing tag is the operator's own words ("<system-reminder> keeps showing, why?"), not the harness's wrapper.
358
+ const tag = leading !== undefined && text.includes(`</${leading}>`) ? leading : undefined;
359
+ if (tag === 'command-name' || tag === 'command-message') {
360
+ const request = commandAsked(text);
361
+ return request !== '' && answeredByModel(lines, position + 1, subagent) ? { opened: 'command', request } : undefined;
362
+ }
363
+ return tag !== undefined && INJECTED_TAGS.has(tag) ? undefined : { opened: 'typed', request: text };
364
+ };
365
+ // parse_sessions.py first_text: the first text block that is not empty.
366
+ const firstNonEmptyText = (content) => {
367
+ if (typeof content === 'string')
368
+ return content;
369
+ if (!Array.isArray(content))
370
+ return '';
371
+ for (const block of content)
372
+ if (isObj(block) && block.type === 'text' && (str(block.text) ?? '') !== '')
373
+ return str(block.text) ?? '';
374
+ return '';
375
+ };
376
+ const majority = (counts) => {
377
+ let best = '?';
378
+ let bestCount = 0;
379
+ for (const [key, count] of counts) {
380
+ if (count > bestCount) {
381
+ best = key;
382
+ bestCount = count;
383
+ }
384
+ }
385
+ return best;
386
+ };
387
+ /** The majority of `counts`, or null when it is empty. */
388
+ const majorityOrNull = (counts) => (counts.size === 0 ? null : majority(counts));
389
+ const bump = (counts, key) => {
390
+ counts.set(key, (counts.get(key) ?? 0) + 1);
391
+ };
392
+ const PATH_KEYS = ['file_path', 'notebook_path', 'path'];
393
+ const toForward = (path) => path.replace(/\\/g, '/');
394
+ // `~/x` is the home directory, never the cwd: joined to a cwd it would hash per cwd and could read as inside the repository.
395
+ const isAbsolute = (path) => path.startsWith('/') || path.startsWith('~/') || /^[A-Za-z]:\//.test(path);
396
+ /** A base directory as `pathKey` compares it: normalized like the path (`posix.normalize` turns a UNC `//` into `/`), no trailing slash. */
397
+ const baseOf = (base) => posix.normalize(toForward(base)).replace(/\/+$/, '');
398
+ /** `path` below `base` (compared without case, as Windows paths are), or undefined when it lies elsewhere. */
399
+ const below = (path, base) => {
400
+ const trimmed = baseOf(base);
401
+ return path.toLowerCase().startsWith(`${trimmed.toLowerCase()}/`) ? path.slice(trimmed.length + 1) : undefined;
402
+ };
403
+ /** A linked worktree of the repository (`.claude/worktrees/<name>/`) is the same tree as the root: A9 counted one file from many of them. At any depth, for a cwd above the repository. */
404
+ const WORKTREE_SEGMENT = /(^|\/)\.claude\/worktrees\/[^/]+(?:\/|$)/;
405
+ /**
406
+ * What a path is hashed as, and from where. Under the repository root of the line's `cwd` it is relative to that root, with a linked
407
+ * worktree's segment stripped, so one file reads alike from every subdirectory and worktree (base `repo`). A path no repository holds
408
+ * keeps the version-3 rule: relative to `cwd` when it lies under it, else as written (base `cwd`). A relative path is read from `cwd` first.
409
+ */
410
+ export const pathKey = (path, cwd, root) => {
411
+ const given = toForward(path);
412
+ const joined = isAbsolute(given) || cwd === undefined ? given : `${toForward(cwd).replace(/\/+$/, '')}/${given}`;
413
+ // `../`, `./`, and a trailing slash must not split one file into several hashes, nor hide that it lies outside the repository.
414
+ const absolute = posix.normalize(joined).replace(/(.)\/+$/, '$1');
415
+ // The root itself (a Grep or Glob `path` at the repository root) is key `.`, as is a bare worktree root.
416
+ const fromRoot = root === null ? undefined : below(absolute, root) ?? (absolute.toLowerCase() === baseOf(root).toLowerCase() ? '' : undefined);
417
+ if (fromRoot !== undefined)
418
+ return { key: fromRoot.replace(WORKTREE_SEGMENT, '$1').replace(/\/+$/, '') || '.', base: 'repo' };
419
+ const fromCwd = cwd === undefined ? undefined : below(absolute, cwd);
420
+ return { key: fromCwd ?? absolute, base: 'cwd' };
421
+ };
422
+ /** The per-account salted hash of a file path (16 hex characters, class A). */
423
+ export const hashPath = (path, cwd, root, salt) => {
424
+ const { key, base } = pathKey(path, cwd, root);
425
+ return { hash: sha256(`${salt}\n${key}`).slice(0, 16), base, key };
426
+ };
427
+ /** A leading `cd <dir> &&` or `cd <dir>;` hop: the target is the command that ran after it. */
428
+ const CD_HOP = /^\s*(?:cd|Set-Location|pushd)\s+\S+\s*(?:&&|;)\s*/;
429
+ /** Leading tokens that only set up the command: an environment assignment, `sudo`, `env`, PowerShell's `&`, and `.`. */
430
+ const PRELUDE = /^(?:[A-Za-z_][A-Za-z0-9_]*=.*|sudo|env|&|\.)$/;
431
+ const SEPARATORS = new Set(['&&', '|', ';']);
432
+ /**
433
+ * A shell call's program and subcommand from its first line (`git push`, `just build`, `npm`), as recovery_episodes.py's
434
+ * `shell_target` computes it, so a retry of `git push` after a failed `git push` is the same target and `git status` is not.
435
+ * The program is a basename, lowercased, without `.exe`; the subcommand is the first later token that is not a flag, a path, or
436
+ * a variable, cut to 30 characters, and there is none when a separator comes first. Empty when the line holds no command.
437
+ */
438
+ export const shellTarget = (command) => {
439
+ let line = command.trim() === '' ? '' : (command.split(/\r?\n/)[0] ?? '');
440
+ for (let hop = CD_HOP.exec(line); hop !== null; hop = CD_HOP.exec(line))
441
+ line = line.slice(hop[0].length);
442
+ const tokens = line
443
+ .split(/\s+/)
444
+ .filter((token) => token !== '')
445
+ .map((token) => token.replace(/^['"]+|['"]+$/g, ''));
446
+ while (tokens.length > 0 && PRELUDE.test(tokens[0] ?? ''))
447
+ tokens.shift();
448
+ const [head, ...rest] = tokens;
449
+ if (head === undefined)
450
+ return '';
451
+ const program = (head.replace(/\\/g, '/').split('/').pop() ?? head).toLowerCase().replace(/\.exe$/, '');
452
+ for (const token of rest) {
453
+ if (SEPARATORS.has(token))
454
+ break;
455
+ if (!/^[-$]/.test(token) && !/[\\/]/.test(token))
456
+ return `${program} ${token.slice(0, 30)}`;
457
+ }
458
+ return program;
459
+ };
460
+ // The captured reading of a shell command (CAPTURE 6). `shellTarget` above stays the retry identity; this reads every line and every
461
+ // segment, because most commits and pulls sit behind another command. It is a reading, not a parser: a torn or exotic command yields
462
+ // fewer facts, never a throw.
463
+ /** Keeps a record bounded on a generated script; the last commands are the ones the call's status can report. */
464
+ const SEGMENTS_PER_CALL_MAX = 50;
465
+ const RUN_WORDS_MAX = 12;
466
+ const RUN_WORD_CHARS = 30;
467
+ const HEREDOC = /(?<!<)<<-?\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_][\w.-]*))/g;
468
+ // The `@` opens a here-string only at the start of a line or after whitespace, `=`, or `(`: `echo "me@"` ends in `@"` and opens nothing.
469
+ const HERE_STRING = /(?<=^|[\s=(])@(['"])\s*$/;
470
+ /**
471
+ * Whether each character of a line sits in literal quoted text, where a `<<` opens no heredoc: `git commit -m "x << y"`. A `$(` inside
472
+ * double quotes is code again, so `"$(cat <<'EOF'` still opens one. A line is read alone, so a quote left open on an earlier line is not seen.
473
+ */
474
+ const literalMask = (line) => {
475
+ const literal = [];
476
+ const stack = [];
477
+ for (let at = 0; at < line.length; at += 1) {
478
+ const char = line.charAt(at);
479
+ const top = stack.at(-1);
480
+ literal[at] = top === "'" || top === '"';
481
+ if (top === "'") {
482
+ if (char === "'")
483
+ stack.pop();
484
+ }
485
+ else if (char === '\\') {
486
+ literal[at + 1] = top === '"';
487
+ at += 1;
488
+ }
489
+ else if (char === '"') {
490
+ if (top === '"')
491
+ stack.pop();
492
+ else
493
+ stack.push('"');
494
+ }
495
+ else if (char === "'" && top !== '"')
496
+ stack.push("'");
497
+ else if (char === '$' && line.charAt(at + 1) === '(') {
498
+ stack.push('(');
499
+ literal[at + 1] = false;
500
+ at += 1;
501
+ }
502
+ else if (char === ')' && top === '(')
503
+ stack.pop();
504
+ }
505
+ return literal;
506
+ };
507
+ /** A heredoc or here-string body is data, and it may hold a whole script, so it goes before anything reads the command. */
508
+ const dropBodies = (command) => {
509
+ const kept = [];
510
+ let tag = null;
511
+ let closer = null;
512
+ for (const line of command.split(/\r?\n/)) {
513
+ if (tag !== null) {
514
+ if (line.trim() === tag)
515
+ tag = null;
516
+ continue;
517
+ }
518
+ if (closer !== null) {
519
+ if (!line.startsWith(closer))
520
+ continue;
521
+ // What follows the closer belongs to the opening line: `@'...'@ | git commit -F -`.
522
+ kept[kept.length - 1] = `${kept[kept.length - 1] ?? ''}${line.slice(closer.length)}`;
523
+ closer = null;
524
+ continue;
525
+ }
526
+ const hereString = HERE_STRING.exec(line);
527
+ if (hereString !== null) {
528
+ kept.push(line.slice(0, hereString.index));
529
+ closer = `${hereString[1] ?? ''}@`;
530
+ continue;
531
+ }
532
+ kept.push(line);
533
+ // Two heredocs on a line close in order, so reading to the last tag drops both bodies.
534
+ const literal = literalMask(line);
535
+ const last = [...line.matchAll(HEREDOC)].filter((match) => literal[match.index] !== true).at(-1);
536
+ if (last !== undefined)
537
+ tag = last[1] ?? last[2] ?? last[3] ?? null;
538
+ }
539
+ return kept.join('\n');
540
+ };
541
+ /** Stands in for the `@` of a version while the redactor runs. A private-use character, so no command holds it. */
542
+ const VERSION_AT = String.fromCharCode(0xe000);
543
+ const VERSIONED = /^(@?[^@:\s]+)@(?!\d+\.\d+\.\d+\.\d+$)(v?\d+(?:\.(?:\d+|x|\*))+(?:[-+][0-9A-Za-z.+-]*)?)$/;
544
+ /** `foo@1.2.3` reads as an email to the redactor. No mail domain is all numbers, while an IP address is a real host, so only a version is shielded. */
545
+ const redactCommand = (command, redact) => redact(command.replace(/[^\s"'`]+/g, (word) => word.replace(VERSIONED, `$1${VERSION_AT}$2`))).replaceAll(VERSION_AT, '@');
546
+ const MASK = /^<(?:secret|email|org-domain)>/;
547
+ const ONLY_MASKS = /^(?:<(?:secret|email|org-domain)>)+$/;
548
+ const tokenize = (text) => {
549
+ const tokens = [];
550
+ let word = '';
551
+ let quoted = false;
552
+ let started = false;
553
+ let redirected = false;
554
+ const flush = () => {
555
+ if (started && redirected)
556
+ redirected = false;
557
+ else if (started && !quoted && (word === '{' || word === '}'))
558
+ tokens.push(word === '{' ? 'open' : 'close');
559
+ else if (started)
560
+ tokens.push({ text: word, quoted });
561
+ word = '';
562
+ quoted = false;
563
+ started = false;
564
+ };
565
+ const op = (token) => {
566
+ flush();
567
+ redirected = false;
568
+ const last = tokens.at(-1);
569
+ // A line that ends in an operator continues on the next one.
570
+ if (token === '\n' && (last === undefined || last === '&&' || last === '||' || last === '|' || last === '\n'))
571
+ return;
572
+ tokens.push(token);
573
+ };
574
+ let at = 0;
575
+ while (at < text.length) {
576
+ const char = text.charAt(at);
577
+ const next = text.charAt(at + 1);
578
+ if (char === "'") {
579
+ const end = text.indexOf("'", at + 1);
580
+ const stop = end < 0 ? text.length : end;
581
+ word += text.slice(at + 1, stop);
582
+ quoted = true;
583
+ started = true;
584
+ at = stop + 1;
585
+ }
586
+ else if (char === '"') {
587
+ quoted = true;
588
+ started = true;
589
+ at += 1;
590
+ while (at < text.length && text.charAt(at) !== '"') {
591
+ if (text.charAt(at) === '\\' && text.charAt(at + 1) === '\n')
592
+ at += 2;
593
+ else if (text.charAt(at) === '\\' && text.charAt(at + 1) === '"') {
594
+ word += '"';
595
+ at += 2;
596
+ }
597
+ else {
598
+ word += text.charAt(at);
599
+ at += 1;
600
+ }
601
+ }
602
+ at += 1;
603
+ }
604
+ else if ((char === '\\' || char === '`') && next === '\n') {
605
+ at += 2;
606
+ }
607
+ else if (char === '\\' && next !== '' && /[\s&|;()<>'"]/.test(next)) {
608
+ word += next;
609
+ started = true;
610
+ at += 2;
611
+ }
612
+ else if (char === '#' && !started) {
613
+ const end = text.indexOf('\n', at);
614
+ at = end < 0 ? text.length : end;
615
+ }
616
+ else if (char === '\n') {
617
+ op('\n');
618
+ at += 1;
619
+ }
620
+ else if (/\s/.test(char)) {
621
+ flush();
622
+ at += 1;
623
+ }
624
+ else if (char === '<' && MASK.test(text.slice(at, at + 12))) {
625
+ const mask = MASK.exec(text.slice(at, at + 12))?.[0] ?? '';
626
+ word += mask;
627
+ started = true;
628
+ at += mask.length;
629
+ }
630
+ else if (char === '>' || char === '<' || (char === '&' && next === '>')) {
631
+ // A file descriptor number belongs to the redirect, and so does the word after it.
632
+ if (started && !quoted && /^\d+$/.test(word)) {
633
+ word = '';
634
+ started = false;
635
+ }
636
+ flush();
637
+ at += 1;
638
+ while (at < text.length && /[<>&]/.test(text.charAt(at)))
639
+ at += 1;
640
+ redirected = true;
641
+ }
642
+ else if (char === '&' && next === '&') {
643
+ op('&&');
644
+ at += 2;
645
+ }
646
+ else if (char === '|' && next === '|') {
647
+ op('||');
648
+ at += 2;
649
+ }
650
+ else if (char === '|') {
651
+ op('|');
652
+ at += next === '&' ? 2 : 1;
653
+ }
654
+ else if (char === ';') {
655
+ op(';');
656
+ at += next === ';' ? 2 : 1;
657
+ }
658
+ else if (char === '&') {
659
+ op('&');
660
+ at += 1;
661
+ }
662
+ else if (char === '(' || char === ')') {
663
+ op(char === '(' ? 'open' : 'close');
664
+ at += 1;
665
+ }
666
+ else {
667
+ word += char;
668
+ started = true;
669
+ at += 1;
670
+ }
671
+ }
672
+ flush();
673
+ return tokens;
674
+ };
675
+ /** A block keyword names no program, and its condition or body is the command. `for` stays: its words say what it walks. */
676
+ const BLOCK_WORDS = new Set(['if', 'then', 'elif', 'else', 'elseif', 'do', 'while', 'until', 'fi', 'done', 'esac', 'try', 'finally', '!']);
677
+ const WRAPPERS = new Set(['sudo', 'env', '.', 'command', 'exec', 'nohup', 'nice', 'time', 'call']);
678
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
679
+ /** The flags of a wrapper that own the next word: `sudo -u root npm i`, `env -u X`, `nice -n 5`, `timeout -k 5 60`. */
680
+ const WRAPPER_VALUE_FLAGS = new Map([
681
+ ['sudo', ['-u', '-g']],
682
+ ['env', ['-u']],
683
+ ['nice', ['-n']],
684
+ ['timeout', ['-k', '-s']],
685
+ ]);
686
+ const POWERSHELL_VARIABLE = /^\$[A-Za-z_]\w*$/;
687
+ const COMMAND_START = /^[A-Za-z_.\\/~]/;
688
+ /** A PowerShell `$name = <command>` at `at`: the variable and the `=` hold no program, and the command after them is the real one. A quoted or numeric value is no command. */
689
+ const assignsCommand = (words, at) => {
690
+ const [name, equals, value] = [words[at], words[at + 1], words[at + 2]];
691
+ return name !== undefined && !name.quoted && POWERSHELL_VARIABLE.test(name.text) && equals?.text === '=' && !equals.quoted && value !== undefined && !value.quoted && COMMAND_START.test(value.text);
692
+ };
693
+ /**
694
+ * The words from the command's real program on. A block word is known by its word in any case (`IF`), and a wrapper by its program name as `programOf`
695
+ * reads it: `TIMEOUT 5` and `/usr/bin/timeout 5` are the wrapper they spell. A shell keyword is never written with a path, so `./do` and `./done` are
696
+ * programs. The private program behind a block word or wrapper is the one read (register row A53).
697
+ */
698
+ const afterPrelude = (words) => {
699
+ let at = 0;
700
+ for (;;) {
701
+ const word = words[at];
702
+ if (word === undefined)
703
+ return [];
704
+ if (word.quoted)
705
+ return words.slice(at);
706
+ const name = programOf(word) ?? word.text;
707
+ if (BLOCK_WORDS.has(word.text.toLowerCase()) || ASSIGNMENT.test(word.text))
708
+ at += 1;
709
+ else if (assignsCommand(words, at))
710
+ at += 2;
711
+ else if (WRAPPERS.has(name) || name === 'timeout') {
712
+ const isTimeout = name === 'timeout';
713
+ const owning = WRAPPER_VALUE_FLAGS.get(name) ?? [];
714
+ at += 1;
715
+ while (words[at]?.text.startsWith('-') === true)
716
+ at += owning.includes(words[at]?.text ?? '') ? 2 : 1;
717
+ if (isTimeout && /^\d+(?:\.\d+)?[smhd]?$/.test(words[at]?.text ?? ''))
718
+ at += 1;
719
+ }
720
+ else
721
+ return words.slice(at);
722
+ }
723
+ };
724
+ /** The program's basename, or null when the word names none: a variable, a flag, or a quoted sentence. */
725
+ const programOf = (word) => {
726
+ const { text } = word;
727
+ if (text === '' || text.includes('$') || text.startsWith('-'))
728
+ return null;
729
+ if (word.quoted && /\s/.test(text) && !/[\\/]/.test(text))
730
+ return null;
731
+ const base = text.replace(/\\/g, '/').split('/').pop() ?? '';
732
+ const program = base.toLowerCase().replace(/\.(?:exe|cmd|bat)$/, '');
733
+ return program === '' ? null : cut(program, RUN_WORD_CHARS);
734
+ };
735
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'pwsh', 'powershell', 'cmd']);
736
+ const COMMAND_FLAG = /^(?:-[a-z]*c|-command|\/c)$/i;
737
+ /** PowerShell takes any prefix of `-Command` (`-c`, `-com`), so a flag that merely ends in `c`, like `-Exec`, is not the command flag; the other shells end theirs in `c`. */
738
+ const isCommandFlag = (program, text) => program === 'pwsh' || program === 'powershell' ? /^[-/]/.test(text) && text.length >= 2 && '-command'.startsWith(`-${text.slice(1).toLowerCase()}`) : COMMAND_FLAG.test(text);
739
+ /** The shells that run every word after the flag as the command. `bash -c npm test` runs `npm` with `test` as `$0`, so bash and its kin take one word. */
740
+ const JOINING_SHELLS = new Set(['pwsh', 'powershell', 'cmd']);
741
+ /**
742
+ * The command a shell is told to run: `bash -lc "..."`, `pwsh -NoProfile -Command "..."`, `cmd /c ...`. A joining shell's unquoted words pass as
743
+ * they are, not joined into text and read again, so a quoted `-m "a && b"` stays an argument.
744
+ */
745
+ const shellArgument = (program, args) => {
746
+ if (!SHELLS.has(program))
747
+ return null;
748
+ const flag = args.findIndex((word) => !word.quoted && isCommandFlag(program, word.text));
749
+ const first = flag < 0 ? undefined : args[flag + 1];
750
+ if (first === undefined)
751
+ return null;
752
+ return JOINING_SHELLS.has(program) && !first.quoted ? args.slice(flag + 1) : tokenize(first.text);
753
+ };
754
+ /** How many shells deep a command is read. Real commands nest two or three; the cap keeps a torn or hostile one from exhausting the stack. */
755
+ const SHELL_DEPTH_MAX = 8;
756
+ const linkOf = (ops) => {
757
+ if (ops.some((op) => op === ';' || op === '&' || op === '||' || op === '\n'))
758
+ return 'break';
759
+ return ops.includes('|') ? 'pipe' : 'and';
760
+ };
761
+ /** Every simple command in order, with whether the call's status reports it. A shell's argument takes the place of the shell, under the shell's own read. */
762
+ const readCommands = (tokens, depth = 0) => {
763
+ const commands = [];
764
+ let pending = [];
765
+ let words = [];
766
+ // The index of the first command of each group still open.
767
+ const opened = [];
768
+ // Where an `open` in `pending` follows a command's own words (`pytest $(a)`), the group's first command is that command, not the next one.
769
+ const owned = new Map();
770
+ const close = () => {
771
+ const real = afterPrelude(words);
772
+ words = [];
773
+ const [head, ...args] = real;
774
+ const program = head === undefined ? null : programOf(head);
775
+ if (program === null)
776
+ return;
777
+ const previous = commands.at(-1);
778
+ if (previous !== undefined)
779
+ previous.link = linkOf(pending);
780
+ // The tokens say which way each group goes, so they are walked in order: an open pushes, a close pops, and a pipe after that close hides the group.
781
+ // A group that holds no program (`{ echo $f }` after `if ($y)`) therefore pops its own open, never an outer one.
782
+ pending.forEach((op, at) => {
783
+ if (op === 'open')
784
+ opened.push(owned.get(at) ?? commands.length);
785
+ else if (op === 'close') {
786
+ const start = opened.pop();
787
+ if (start !== undefined && pending.includes('|', at + 1))
788
+ for (const held of commands.slice(start))
789
+ held.hidden = true;
790
+ }
791
+ });
792
+ owned.clear();
793
+ pending = [];
794
+ commands.push({ program, args, read: true, link: 'end', hidden: false });
795
+ };
796
+ for (const token of tokens) {
797
+ if (typeof token === 'string') {
798
+ const behindWords = token === 'open' && words.length > 0;
799
+ const before = commands.length;
800
+ close();
801
+ if (behindWords && commands.length > before)
802
+ owned.set(pending.length, before);
803
+ pending.push(token);
804
+ }
805
+ else
806
+ words.push(token);
807
+ }
808
+ close();
809
+ // Walking back, a later `;`, `&`, newline, or `||` means the status is no longer this command's; a pipe hides the command it follows and every command of a group closed before it.
810
+ let overridden = pending.includes('&');
811
+ for (const command of commands.toReversed()) {
812
+ if (command.link === 'break')
813
+ overridden = true;
814
+ command.read = !overridden && command.link !== 'pipe' && !command.hidden;
815
+ }
816
+ return commands.flatMap((command) => {
817
+ // Past the cap a shell is a plain command: `'pwsh -c '.repeat(5000)` would otherwise recurse once per word and overflow the stack.
818
+ const argument = depth >= SHELL_DEPTH_MAX ? null : shellArgument(command.program, command.args);
819
+ if (argument === null)
820
+ return [command];
821
+ const inner = readCommands(argument, depth + 1);
822
+ if (inner.length === 0)
823
+ return [command];
824
+ for (const held of inner)
825
+ held.read &&= command.read;
826
+ const last = inner.at(-1);
827
+ if (last !== undefined)
828
+ last.link = command.link;
829
+ return inner;
830
+ });
831
+ };
832
+ const isPlainWord = (word) => !word.quoted && word.text !== '' && !/^-|\$|[\\/]/.test(word.text);
833
+ /**
834
+ * The program and its plain words, joined by one space. A basename that holds whitespace (`"/opt/my tools/git push"`) would read back as a program and a
835
+ * word, so capture stores `<literal>` in its place: the boundary a `run` cannot carry, and no part of that name is kept (register row A53).
836
+ */
837
+ const runOf = (command) => [/\s/.test(command.program) ? LITERAL : command.program, ...command.args.filter(isPlainWord).map((word) => cut(word.text, RUN_WORD_CHARS))].slice(0, RUN_WORDS_MAX).join(' ');
838
+ /** Positional words and flag values. A flag in `takesValue` owns the next word, which is therefore not a name. */
839
+ const parseArgs = (args, takesValue) => {
840
+ const names = [];
841
+ const nameAt = [];
842
+ const flags = new Map();
843
+ for (let at = 0; at < args.length; at += 1) {
844
+ const text = args[at]?.text ?? '';
845
+ if (!text.startsWith('-')) {
846
+ names.push(text);
847
+ nameAt.push(at);
848
+ }
849
+ else if (text.includes('='))
850
+ flags.set(text.slice(0, text.indexOf('=')), text.slice(text.indexOf('=') + 1));
851
+ else if (takesValue.has(text)) {
852
+ flags.set(text, args[at + 1]?.text ?? '');
853
+ at += 1;
854
+ }
855
+ }
856
+ return { names, flags, nameAt };
857
+ };
858
+ // `-w` and `-s` are left out: `-w` owns a value only on npm (`--workspace`; on pnpm it means the workspace root) and `-s` only for `skills add` (on npm it is silent).
859
+ const NODE_VALUE_FLAGS = new Set(['--workspace', '--registry', '--prefix', '--filter', '-F', '-C', '--dir', '--cache', '--tag', '--cwd', '--loglevel', '--userconfig', '-p', '--package', '-c', '--call', '--omit', '--include', '--save-prefix', '--skill']);
860
+ const NPM_VALUE_FLAGS = new Set([...NODE_VALUE_FLAGS, '-w']);
861
+ const SKILLS_VALUE_FLAGS = new Set(['-s']);
862
+ const nodeFlags = (program) => (program === 'npm' || program === 'npx' ? NPM_VALUE_FLAGS : NODE_VALUE_FLAGS);
863
+ const PYTHON_VALUE_FLAGS = new Set(['-r', '--requirement', '-e', '--editable', '-c', '--constraint', '-i', '--index-url', '--extra-index-url', '--index', '--default-index', '-t', '--target', '-f', '--find-links', '--python', '-p', '--from', '--with', '--prefix', '--root', '--group', '--extra', '--only-binary', '--no-binary', '--trusted-host', '--proxy', '--cache-dir', '--timeout', '--suffix', '--pip-args', '--spec']);
864
+ const CARGO_VALUE_FLAGS = new Set(['--path', '--git', '--branch', '--tag', '--rev', '--root', '--features', '-F', '--target', '--registry', '--index', '-j', '--jobs', '--profile', '--bin', '--example', '--package', '-p', '--rename', '--manifest-path', '--version', '--vers', '--target-dir', '--config']);
865
+ const GO_VALUE_FLAGS = new Set(['-o', '-tags', '-modfile', '-C', '-ldflags', '-gcflags', '-mod', '-p']);
866
+ const GEM_VALUE_FLAGS = new Set(['-v', '--version', '-i', '--install-dir', '-n', '--bindir', '-s', '--source', '--platform']);
867
+ const PLUGIN_VALUE_FLAGS = new Set(['--scope', '-s']);
868
+ const NO_VALUE_FLAGS = new Set();
869
+ const LOCAL_SPEC = /^(?:\.{1,2}$|\.{0,2}[\\/]|~|[A-Za-z]:[\\/]|file:|link:|workspace:|portal:)/;
870
+ const ARCHIVE = /\.(?:whl|tgz|zip|tar\.gz)$/;
871
+ const isUrl = (text) => text.includes('://');
872
+ const namesNothing = (text) => text === '' || text.includes('$') || /^%.*%$/.test(text) || ONLY_MASKS.test(text);
873
+ const HOLDS_MASK = /<(?:secret|email|org-domain)>/;
874
+ /** A version with a mask inside says only that something was masked: `alias@npm:foo@1.2.3-beta.1` reads as `npm:<email>`. It is no version. */
875
+ const versionOf = (text) => (text === '' || text.includes('$') || HOLDS_MASK.test(text) ? null : text);
876
+ /** `name@version`, keeping a leading `@` with the name. */
877
+ const atVersion = (ecosystem, text) => {
878
+ const at = text.lastIndexOf('@');
879
+ const name = at > 0 ? text.slice(0, at) : text;
880
+ const version = at > 0 ? text.slice(at + 1) : '';
881
+ // A name that starts with a mask is not a package: `git@github.com:org/repo.git` reads as `<email>:org/repo.git`. A Go module path may start with `<org-domain>`.
882
+ if (namesNothing(name) || (ecosystem !== 'go' && MASK.test(name)))
883
+ return null;
884
+ return { ecosystem, name, version: versionOf(version) };
885
+ };
886
+ const wholeName = (ecosystem, text) => (namesNothing(text) ? null : { ecosystem, name: text, version: null });
887
+ // `alias@npm:real@1.0.0` and a leading `npm:real` install the package `real`: the pick is the real name, never the alias spelling.
888
+ const NPM_ALIAS = /^(?:(?:@[^@/\s]+\/)?[^@/\s]+@)?npm:(.+)$/;
889
+ const nodeSpec = (spec) => {
890
+ const text = NPM_ALIAS.exec(spec)?.[1] ?? spec;
891
+ if (isUrl(text))
892
+ return wholeName('npm', text);
893
+ if (LOCAL_SPEC.test(text) || ARCHIVE.test(text))
894
+ return null;
895
+ // One slash is a scoped name or a GitHub shorthand; more is a folder.
896
+ if (text.split('/').length > 2 || text.includes('\\'))
897
+ return null;
898
+ return atVersion('npm', text);
899
+ };
900
+ const PYTHON_SPEC = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(===?|~=|!=|>=|<=|<|>|@)?(.*)$/;
901
+ const pythonSpec = (text) => {
902
+ if (isUrl(text))
903
+ return wholeName('pypi', text);
904
+ if (LOCAL_SPEC.test(text) || ARCHIVE.test(text) || /[\\/]/.test(text))
905
+ return null;
906
+ const match = PYTHON_SPEC.exec(text);
907
+ if (match === null)
908
+ return null;
909
+ const operator = match[2] ?? '';
910
+ const stated = match[3] ?? '';
911
+ const version = versionOf(operator === '==' || operator === '===' || operator === '@' ? stated : stated === '' ? '' : `${operator}${stated}`);
912
+ return { ecosystem: 'pypi', name: match[1] ?? '', version };
913
+ };
914
+ /** A module path's first element has a dot; `fmt`, `cmd/x`, and `all` are not downloads. */
915
+ const goSpec = (text) => (/^(?:<org-domain>|[A-Za-z0-9-]+\.[^/\\]+)\//.test(text) ? atVersion('go', text) : null);
916
+ const localOr = (ecosystem, text) => (LOCAL_SPEC.test(text) ? null : atVersion(ecosystem, text));
917
+ const present = (pull) => pull !== null;
918
+ /** `npx`, `bunx`, and `dlx` run one package: a `--package` flag names it, else the first word. `skills add` installs a skill rather than running a tool. */
919
+ const runnerPulls = (args, valueFlags, outer = new Map()) => {
920
+ let { names, flags } = parseArgs(args, valueFlags);
921
+ const [first, sub] = names;
922
+ const skills = first !== undefined && atVersion('npm', first)?.name === 'skills' && sub === 'add';
923
+ // `-s` names a skill there, so it owns the next word: `npx skills add -s one owner/repo`.
924
+ if (skills)
925
+ ({ names, flags } = parseArgs(args, new Set([...valueFlags, ...SKILLS_VALUE_FLAGS])));
926
+ const source = names[2];
927
+ if (skills && source !== undefined)
928
+ return [LOCAL_SPEC.test(source) ? null : wholeName('skill', source)];
929
+ const packaged = flags.get('-p') ?? flags.get('--package') ?? outer.get('-p') ?? outer.get('--package');
930
+ if (packaged !== undefined)
931
+ return [nodeSpec(packaged)];
932
+ return first === undefined ? [] : [nodeSpec(first)];
933
+ };
934
+ const nodePulls = (program, args) => {
935
+ const valueFlags = nodeFlags(program);
936
+ const { names, flags, nameAt } = parseArgs(args, valueFlags);
937
+ // `global` and `workspace <name>` only say where the install goes.
938
+ const lead = names[0] === 'global' ? 1 : names[0] === 'workspace' ? 2 : 0;
939
+ const [sub, ...specs] = names.slice(lead);
940
+ // The words after the `dlx` name, found by its place in the parse rather than by its text, since a flag's value can read `dlx` too; a flag before it (pnpm's `--package=foo dlx bar`) still counts.
941
+ if (sub === 'dlx')
942
+ return runnerPulls(args.slice((nameAt[lead] ?? 0) + 1), valueFlags, flags);
943
+ return sub === 'add' || sub === 'install' || sub === 'i' ? specs.map(nodeSpec) : [];
944
+ };
945
+ const pipPulls = (args) => {
946
+ const { names } = parseArgs(args, PYTHON_VALUE_FLAGS);
947
+ return names[0] === 'install' ? names.slice(1).map(pythonSpec) : [];
948
+ };
949
+ const uvxPulls = (args) => {
950
+ const { names, flags } = parseArgs(args, PYTHON_VALUE_FLAGS);
951
+ const named = flags.get('--from') ?? names[0];
952
+ return named === undefined ? [] : [pythonSpec(named)];
953
+ };
954
+ const uvPulls = (args) => {
955
+ const { names } = parseArgs(args, PYTHON_VALUE_FLAGS);
956
+ if (names[0] === 'add')
957
+ return names.slice(1).map(pythonSpec);
958
+ if ((names[0] === 'pip' || names[0] === 'tool') && names[1] === 'install')
959
+ return names.slice(2).map(pythonSpec);
960
+ return [];
961
+ };
962
+ const pipxPulls = (args) => {
963
+ const { names, flags } = parseArgs(args, PYTHON_VALUE_FLAGS);
964
+ if (names[0] === 'install')
965
+ return names.slice(1).map(pythonSpec);
966
+ const named = flags.get('--spec') ?? names[1];
967
+ return names[0] === 'run' && named !== undefined ? [pythonSpec(named)] : [];
968
+ };
969
+ /** A name's version from its own `@`, else from a version flag. */
970
+ const versionedPulls = (ecosystem, names, stated) => names
971
+ .map((name) => localOr(ecosystem, name))
972
+ .map((pull) => (pull !== null && pull.version === null && stated !== undefined && versionOf(stated) !== null ? { ...pull, version: stated } : pull));
973
+ const pullsOfCommand = (program, args) => {
974
+ if (program === 'npm' || program === 'pnpm' || program === 'yarn' || program === 'bun')
975
+ return nodePulls(program, args);
976
+ if (program === 'npx' || program === 'bunx')
977
+ return runnerPulls(args, nodeFlags(program));
978
+ if (/^pip[\d.]*$/.test(program))
979
+ return pipPulls(args);
980
+ if (/^(?:python[\d.]*|py)$/.test(program)) {
981
+ const module = args.findIndex((word) => word.text === '-m');
982
+ return module >= 0 && /^pip[\d.]*$/.test(args[module + 1]?.text ?? '') ? pipPulls(args.slice(module + 2)) : [];
983
+ }
984
+ if (program === 'uv')
985
+ return uvPulls(args);
986
+ if (program === 'uvx')
987
+ return uvxPulls(args);
988
+ if (program === 'pipx')
989
+ return pipxPulls(args);
990
+ if (program === 'cargo') {
991
+ // A leading `+toolchain` word picks the compiler and is no subcommand.
992
+ const { names, flags } = parseArgs(args[0]?.text.startsWith('+') === true ? args.slice(1) : args, CARGO_VALUE_FLAGS);
993
+ if (names[0] !== 'add' && names[0] !== 'install')
994
+ return [];
995
+ const git = flags.get('--git');
996
+ // With no name, `--git` is the thing pulled.
997
+ if (names.length === 1 && git !== undefined)
998
+ return [LOCAL_SPEC.test(git) ? null : wholeName('cargo', git)];
999
+ return versionedPulls('cargo', names.slice(1), flags.get('--version') ?? flags.get('--vers'));
1000
+ }
1001
+ if (program === 'go') {
1002
+ const { names } = parseArgs(args, GO_VALUE_FLAGS);
1003
+ return names[0] === 'get' || names[0] === 'install' ? names.slice(1).map(goSpec) : [];
1004
+ }
1005
+ if (program === 'gem') {
1006
+ const { names, flags } = parseArgs(args, GEM_VALUE_FLAGS);
1007
+ return names[0] === 'install' ? versionedPulls('gem', names.slice(1), flags.get('-v') ?? flags.get('--version')) : [];
1008
+ }
1009
+ if (program === 'brew') {
1010
+ const { names } = parseArgs(args, NO_VALUE_FLAGS);
1011
+ return names[0] === 'install' ? versionedPulls('brew', names.slice(1), undefined) : [];
1012
+ }
1013
+ if (program === 'claude' || program === 'codex') {
1014
+ // `foo@market` names a marketplace, not a version, so the name stays whole.
1015
+ const { names } = parseArgs(args, PLUGIN_VALUE_FLAGS);
1016
+ const verb = program === 'claude' ? 'install' : 'add';
1017
+ return names[0] === 'plugin' && names[1] === verb ? names.slice(2).map((name) => wholeName('plugin', name)) : [];
1018
+ }
1019
+ return [];
1020
+ };
1021
+ const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
1022
+ const GIT_SCOPE_EQUALS = /^--(?:git-dir|work-tree|namespace)=/;
1023
+ const gitCall = (command) => {
1024
+ if (command.program !== 'git')
1025
+ return null;
1026
+ let scope = '';
1027
+ for (let at = 0; at < command.args.length; at += 1) {
1028
+ const text = command.args[at]?.text ?? '';
1029
+ if (GIT_VALUE_FLAGS.has(text)) {
1030
+ if (text !== '-c')
1031
+ scope += `${text} ${command.args[at + 1]?.text ?? ''};`;
1032
+ at += 1;
1033
+ }
1034
+ else if (GIT_SCOPE_EQUALS.test(text)) {
1035
+ // The `--git-dir=/a` form names the repository like `--git-dir /a`, so it is the same scope.
1036
+ scope += `${text.slice(0, text.indexOf('='))} ${text.slice(text.indexOf('=') + 1)};`;
1037
+ }
1038
+ else if (!text.startsWith('-'))
1039
+ return { sub: text, args: command.args.slice(at + 1), scope };
1040
+ }
1041
+ return null;
1042
+ };
1043
+ const NOT_A_COMMIT = /^(?:--dry-run|-h|--help|--porcelain|--short|--long)$/;
1044
+ const QUIET = /^(?:--quiet|-[a-zA-Z]*q[a-zA-Z]*)$/;
1045
+ const MOVES_HEAD = new Set(['checkout', 'switch', 'pull', 'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'am', 'commit']);
1046
+ export const CHANGES_FOLDER = new Set(['cd', 'pushd', 'popd', 'set-location', 'sl', 'chdir']);
1047
+ const LOG_ONE = /^(?:-1|-n1|--max-count=1)$/;
1048
+ const LOG_FILTER = /^--(?:grep|author|committer|since|until|after|before|all|branches|remotes|tags|skip|reverse)(?:=|$)/;
1049
+ const LOG_FORMAT = /^--(?:format|pretty)=/;
1050
+ /** A read of the commit just made: `rev-parse [--short] HEAD`, or a one-commit `log` of HEAD whose format prints a hash. */
1051
+ const readsHead = (git) => {
1052
+ const words = git.args.map((word) => word.text);
1053
+ if (git.sub === 'rev-parse') {
1054
+ // `--abbrev-ref` and `--symbolic` print a branch or a ref, never a sha.
1055
+ if (words.some((word) => word.startsWith('--abbrev-ref') || word.startsWith('--symbolic')))
1056
+ return false;
1057
+ const refs = words.filter((word) => !word.startsWith('-'));
1058
+ return refs.length === 1 && refs[0] === 'HEAD';
1059
+ }
1060
+ if (git.sub !== 'log')
1061
+ return false;
1062
+ const count = words.indexOf('-n');
1063
+ const one = words.some((word) => LOG_ONE.test(word)) || (count >= 0 && words[count + 1] === '1');
1064
+ if (!one || words.includes('--') || words.some((word) => LOG_FILTER.test(word)))
1065
+ return false;
1066
+ const format = words.find((word) => LOG_FORMAT.test(word));
1067
+ if (format?.includes('%') === true && !/%[hH]/.test(format))
1068
+ return false;
1069
+ return words.every((word, at) => word.startsWith('-') || word === 'HEAD' || (at === count + 1 && count >= 0));
1070
+ };
1071
+ /** Whether the call's final command reads this commit back. `commitsRead` takes the result's last hash-shaped line, and only the final command is sure to have printed it. */
1072
+ const isReadBack = (commands, from, scope) => {
1073
+ for (let at = from; at + 1 < commands.length; at += 1) {
1074
+ // Behind `;` the commit may have failed, and the read names an older commit.
1075
+ if (commands[at]?.link === 'break')
1076
+ return false;
1077
+ const next = commands[at + 1];
1078
+ if (next === undefined || CHANGES_FOLDER.has(next.program))
1079
+ return false;
1080
+ const git = gitCall(next);
1081
+ if (git !== null && MOVES_HEAD.has(git.sub))
1082
+ return false;
1083
+ if (git?.scope === scope && readsHead(git))
1084
+ return at + 2 === commands.length;
1085
+ }
1086
+ return false;
1087
+ };
1088
+ const commitPlanOf = (commands) => commands.flatMap((command, at) => {
1089
+ const git = gitCall(command);
1090
+ if (git === null || git.sub !== 'commit' || git.args.some((word) => !word.quoted && NOT_A_COMMIT.test(word.text)))
1091
+ return [];
1092
+ return [{ quiet: git.args.some((word) => !word.quoted && QUIET.test(word.text)), readBack: isReadBack(commands, at, git.scope) }];
1093
+ });
1094
+ /** Every simple command of a shell call, in order, after the bodies are dropped and the command is redacted. `oars gate` reads the same commands as capture. */
1095
+ export const shellCommands = (command, redact) => readCommands(tokenize(redactCommand(dropBodies(command.replace(/\r\n/g, '\n')), redact)));
1096
+ /** A shell call's segments, pulls, and the commits it holds, from the command alone. The command is redacted before anything is kept. */
1097
+ export const readShell = (command, redact) => {
1098
+ const commands = shellCommands(command, redact);
1099
+ const kept = commands.slice(-SEGMENTS_PER_CALL_MAX);
1100
+ const pulls = new Map();
1101
+ for (const { program, args } of commands) {
1102
+ for (const pull of pullsOfCommand(program, args).filter(present))
1103
+ pulls.set(JSON.stringify(pull), pull);
1104
+ }
1105
+ return {
1106
+ segments: kept.map((held) => ({ run: runOf(held), read: held.read })),
1107
+ steps: kept.map((held) => ({ identity: shellIdentity(held.program, held.args, pullsOfCommand(held.program, held.args).filter(present), redact), read: held.read, run: runOf(held) })),
1108
+ pulls: [...pulls.values()],
1109
+ commitPlan: commitPlanOf(commands),
1110
+ };
1111
+ };
1112
+ const COMMIT_LINE = /^\[[^\]\n]* ([0-9a-f]{7,40})\]/gm;
1113
+ const COMMIT_REFUSED = /^(?:nothing to commit|nothing added to commit|no changes added to commit|fatal: |error: )/gm;
1114
+ const HASH_LINE = /^(?:commit )?([0-9a-f]{7,40})(?=\s|$)/gm;
1115
+ /**
1116
+ * What a clean result says about the planned commits: one entry per commit made, the sha when its `[branch sha]` line or a read-back
1117
+ * names it, else null, since a count may stand without a sha. A refused commit makes no entry: the call can be clean behind `; echo done`.
1118
+ */
1119
+ export const commitsRead = (plan, result) => {
1120
+ const lines = [...result.matchAll(COMMIT_LINE)].map((match) => match[1] ?? '');
1121
+ let refused = result.match(COMMIT_REFUSED)?.length ?? 0;
1122
+ const hash = [...result.matchAll(HASH_LINE)].at(-1)?.[1] ?? null;
1123
+ const commits = [];
1124
+ for (const planned of plan) {
1125
+ const line = planned.quiet ? undefined : lines.shift();
1126
+ if (line !== undefined)
1127
+ commits.push(line);
1128
+ else if (refused > 0)
1129
+ refused -= 1;
1130
+ else
1131
+ commits.push(planned.readBack ? hash : null);
1132
+ }
1133
+ return commits;
1134
+ };
1135
+ /** The longest MCP name tail masked slice by slice. A tool name is capped near 64 characters and a server name near that, so a longer tail is not a name. */
1136
+ const MCP_TAIL_MAX = 192;
1137
+ /**
1138
+ * The pattern rules of the redactor that can match across a `_`, and so across half of a separator: a class that allows `_` (`sk-`, `AIza`,
1139
+ * `github_pat_`, `eyJ`), and a NAME=value rule, whose value takes anything. `sk-` is the shape the rule is for (`sk-ant-`, 16 characters of the
1140
+ * class before any `_`, or a capital and a digit anywhere in the slice), so `sk-learn` and `task-sk-tools` are not taken for a key.
1141
+ */
1142
+ const UNDERSCORE_TOKEN = /\b(?:sk-(?:ant-|[A-Za-z0-9-]{16}|(?=[\w-]*[A-Z])(?=[\w-]*\d))|AIza|github_pat_|eyJ)|[=:]/;
1143
+ /** The pattern rules a Cursor name can hold across a `_`: a prefix that ends in `_`, plus the `UNDERSCORE_TOKEN` rules, since it has no `__` to cut at. */
1144
+ const CURSOR_TOKEN = /\b(?:sk-(?:ant-|[A-Za-z0-9-]{16}|(?=[\w-]*[A-Z])(?=[\w-]*\d))|[srp]k_(?:live|test)_|AIza|github_pat_|gh[pousr]_|whsec_|eyJ)|[=:]/;
1145
+ /**
1146
+ * What follows `mcp__` (or the Cursor `mcp_`) with every secret masked. A token is matched only at a word boundary and `_` is a word character, so
1147
+ * the redactor is run over every slice of the tail that starts at 0 or right after a `_` and ends at the end or right before a `_`. That
1148
+ * finds a token behind a `_`, in front of one, and one that holds `__` (`sk-`, `AIza`, `github_pat_`, and `eyJ` allow it). The slices are
1149
+ * quadratic in the number of underscores, so a tail past `MCP_TAIL_MAX` characters (which bounds that number) is stored as `<oversized>`.
1150
+ * The tool part comes from the operator's config as much as the server part does. A slice that holds `__`, or starts or ends at a `_` (it holds
1151
+ * half of a separator), is run through the redactor only when it holds one of the `UNDERSCORE_TOKEN` rules: otherwise the entropy rule reads two
1152
+ * harmless parts (`Context7__resolve-library-id`) as one random token, or eats the separator, and the server loses its name. A Cursor name has
1153
+ * no `__` to tell server from tool, so any slice that holds a `_` is run only for a `CURSOR_TOKEN` rule. Limits, each on the side of the secret
1154
+ * or an accepted miss: a public name that starts like a pattern rule (`sk-ant-tools`, `github_pat_helper`) loses its server, since it cannot be
1155
+ * told from a token that holds `__` (`sk-learn__PCA2Transform` and `task-sk-tools__Get2Items` are masked, since a tool part supplies the capital
1156
+ * and the digit); an `sk-` key with neither that holds a `_` before 16 characters, `mcp_sk-abc_defghijklmnopqrstuvwxyz_find`, is stored raw, since it
1157
+ * cannot be told from `mcp_sk-learn_fit_transform`; a name that holds `:` or `=` (`mcp__ns:Context7__resolve-library-id`,
1158
+ * `mcp__ns=Context7__resolve-library-id`) opens a NAME=value slice, so its parts are read as one token and the server is lost (no harness is known to
1159
+ * write such a name); a tool part of 24 characters or more with a capital and a digit is masked, since it cannot be told from a token; a token right
1160
+ * behind a Slack-shaped run keeps what that run's match leaves (`mcp_xoxb-1234567890-ghp_...` stores `mcp_<secret>_...`, `mcp__xoxb-a-eyJ...` keeps
1161
+ * the JWT's other segments), since no earlier start exists to finish it; and a random token with no pattern rule whose every part is under 24
1162
+ * characters (`TOKEN`'s minimum) cannot be told from two ordinary parts and is not found.
1163
+ */
1164
+ const redactedMcpTail = (tail, redact, cursor) => {
1165
+ if (tail.length > MCP_TAIL_MAX)
1166
+ return '<oversized>';
1167
+ let out = tail;
1168
+ const starts = [0];
1169
+ for (let at = tail.indexOf('_'); at !== -1; at = tail.indexOf('_', at + 1))
1170
+ starts.push(at + 1);
1171
+ // Last start first, so an edit never moves a start still to come; the longest slice first, so a token that holds a `_` is cut whole.
1172
+ for (const start of starts.toReversed()) {
1173
+ const ends = [out.length];
1174
+ for (let at = out.indexOf('_', start); at !== -1; at = out.indexOf('_', at + 1))
1175
+ ends.push(at);
1176
+ for (const end of ends.toSorted((left, right) => right - left)) {
1177
+ const slice = out.slice(start, end);
1178
+ if (/__|^_|_$/.test(slice) && !UNDERSCORE_TOKEN.test(slice))
1179
+ continue;
1180
+ if (cursor && slice.includes('_') && !CURSOR_TOKEN.test(slice))
1181
+ continue;
1182
+ const masked = redact(slice);
1183
+ if (masked === slice)
1184
+ continue;
1185
+ out = out.slice(0, start) + masked + out.slice(end);
1186
+ break;
1187
+ }
1188
+ }
1189
+ // A Slack token stops at a `_`, but the redactor backtracks to its `-`, so the rest of the token follows the mask: it goes with the mask.
1190
+ // It goes last, since a later slice has to read a token that rest opens.
1191
+ return out.replace(/<secret>[A-Za-z0-9]+(?=_)/g, '<secret>');
1192
+ };
1193
+ /**
1194
+ * The tool name as a record stores it. An MCP name is `mcp__<server>__<tool>` (Cursor: `mcp_<server>_<tool>`) and the server comes from the
1195
+ * operator's config, where a token can sit, so everything after the prefix is redacted wherever the name is kept (the call, its error line,
1196
+ * the skeleton line, and the session's count).
1197
+ */
1198
+ export const storedToolName = (name, redact) => {
1199
+ const prefix = name.startsWith('mcp__') ? 'mcp__' : name.startsWith('mcp_') ? 'mcp_' : null;
1200
+ if (prefix === null || name.length === prefix.length)
1201
+ return name;
1202
+ return `${prefix}${redactedMcpTail(name.slice(prefix.length), redact, prefix === 'mcp_')}`;
1203
+ };
1204
+ /** An MCP call's full server name, which `shortName` cuts for the tool sequence, redacted because a harness takes it from the operator's config. */
1205
+ export const pullsOfTool = (name, redact) => {
1206
+ if (!name.startsWith('mcp__'))
1207
+ return [];
1208
+ const pull = wholeName('mcp', redactedMcpTail(name.slice('mcp__'.length), redact, false).split('__')[0] ?? '');
1209
+ return pull === null ? [] : [pull];
1210
+ };
1211
+ /** A call's steps: one per command of a shell call, else one. */
1212
+ const draftsOf = (table, named, native, shell, target, input, redact) => {
1213
+ const { action } = named;
1214
+ if (action === 'shell.exec') {
1215
+ const read = shell?.steps ?? [];
1216
+ if (read.length === 0)
1217
+ return [{ action, native: named.native, identity: UNREAD_SHELL, read: true, rawKey: `${native}\n${str(input.command) ?? ''}` }];
1218
+ return read.map((held) => ({ action, native: named.native, identity: held.identity, read: held.read, rawKey: `${native}\n${held.run}` }));
1219
+ }
1220
+ const url = str(input.url);
1221
+ const skill = str(input.skill);
1222
+ return [{ action, ...callIdentity(table, action, native, named.native, { skill, url, redact }), read: true, rawKey: `${native}\n${target ?? url ?? skill ?? ''}` }];
1223
+ };
1224
+ /** The `target` of a tool call (see {@link ToolObservation.target}). A path target also returns the `key` it was hashed on, read for its class and then dropped, never stored. */
1225
+ export const targetOf = (tool, input, cwd, salt, redact, root = null) => {
1226
+ for (const key of PATH_KEYS) {
1227
+ const value = str(input[key]);
1228
+ if (value !== undefined && value !== '') {
1229
+ const { hash, base, key: hashedKey } = hashPath(value, cwd, root, salt);
1230
+ return { target: hash, base, key: hashedKey };
1231
+ }
1232
+ }
1233
+ if (!SHELL.has(tool))
1234
+ return { target: null, base: null };
1235
+ const target = shellTarget(str(input.command) ?? '');
1236
+ return { target: target === '' ? null : redact(target), base: null };
1237
+ };
1238
+ /** The `n` capture gives each call of a transcript, by tool-use id, the first copy of a repeated id winning; a subagent's steps name their parent by it without capturing the parent again. */
1239
+ export const callPositions = (text) => {
1240
+ const positions = new Map();
1241
+ let n = 0;
1242
+ for (const raw of splitLines(text)) {
1243
+ let parsed;
1244
+ try {
1245
+ parsed = JSON.parse(raw);
1246
+ }
1247
+ catch {
1248
+ continue;
1249
+ }
1250
+ const content = isObj(parsed) && isObj(parsed.message) && parsed.type !== 'api-request-blob' ? parsed.message.content : undefined;
1251
+ if (!Array.isArray(content))
1252
+ continue;
1253
+ for (const block of content) {
1254
+ if (!isObj(block) || block.type !== 'tool_use')
1255
+ continue;
1256
+ const id = str(block.id);
1257
+ // Capture writes a replayed call once, so a replay takes no number here either.
1258
+ if (id !== undefined && positions.has(id))
1259
+ continue;
1260
+ if (id !== undefined)
1261
+ positions.set(id, n);
1262
+ n += 1;
1263
+ }
1264
+ }
1265
+ return positions;
1266
+ };
1267
+ /** One pass over a transcript into its capture records and skeleton. Never throws on a malformed line: it counts it and goes on. */
1268
+ export const captureSession = (input) => {
1269
+ const { redact } = input;
1270
+ const salt = input.salt ?? '';
1271
+ const sid = input.sid ?? null;
1272
+ const rootOf = input.repoRoot ?? (() => null);
1273
+ const identityOf = input.repoIdentity ?? ((root) => `path:${toForward(root).toLowerCase()}`);
1274
+ const runtime = input.runtime ?? { oars: '0.0.0', node: process.version, os: process.platform };
1275
+ const origin = input.subagent;
1276
+ const harness = input.harness ?? 'claude-code';
1277
+ const actions = ACTION_TABLES[harness];
1278
+ const rawLines = input.text.split(/\r\n|\n|\r/);
1279
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '')
1280
+ rawLines.pop();
1281
+ const session = {
1282
+ kind: 'session',
1283
+ cv: CAPTURE_VERSION,
1284
+ sid,
1285
+ session: input.session,
1286
+ project: input.project,
1287
+ parent: origin?.parent ?? null,
1288
+ agentType: origin?.agentType ?? null,
1289
+ spawnedBy: origin?.spawnedBy ?? null,
1290
+ spawnedIn: origin?.spawnedIn ?? null,
1291
+ rev: input.rev,
1292
+ at: input.at,
1293
+ bytes: input.bytes,
1294
+ lines: rawLines.length,
1295
+ badLines: 0,
1296
+ userTurns: 0,
1297
+ assistantTurns: 0,
1298
+ tsFirst: null,
1299
+ tsLast: null,
1300
+ versions: [],
1301
+ models: {},
1302
+ branches: [],
1303
+ tools: {},
1304
+ files: [],
1305
+ fileHashes: [],
1306
+ repo: null,
1307
+ urls: [],
1308
+ searches: [],
1309
+ bashCmds: 0,
1310
+ tldrs: [],
1311
+ confidences: [],
1312
+ compactSummaries: 0,
1313
+ promptFingerprint: null,
1314
+ env: { harness: null, oars: runtime.oars, node: runtime.node, os: runtime.os },
1315
+ costUsd: null,
1316
+ usage: emptyUsage(),
1317
+ };
1318
+ const sessionCoverage = emptyCoverage();
1319
+ const versions = new Set();
1320
+ const branches = new Set();
1321
+ const files = new Set();
1322
+ /** Redacted path to its salted hash, so `files` and `fileHashes` are cut from one set of paths. */
1323
+ const fileHashes = new Map();
1324
+ let sessionRoot = null;
1325
+ const urls = new Set();
1326
+ const searches = new Set();
1327
+ const prompts = [];
1328
+ const tools = [];
1329
+ const errorLines = [];
1330
+ const skeletonLines = [];
1331
+ const toolById = new Map();
1332
+ const callById = new Map();
1333
+ // Packages named so far in this file, by package and version, so a call's `first` mark is true once per version.
1334
+ const seenPackages = new Set();
1335
+ // A Read of a manifest shows its names in the result, so the call waits here for it, by tool_use id.
1336
+ const manifestReads = new Map();
1337
+ // The evidence for the frame: read across the whole file, so one session's prompts share a stack and a shell, and held only until the frame is built.
1338
+ const framePaths = [];
1339
+ const framePrograms = new Set();
1340
+ const frameShells = new Map();
1341
+ const fileEfforts = new Map();
1342
+ const fileModels = new Map();
1343
+ const fileMessageModels = new Map();
1344
+ const promptFrames = [];
1345
+ /** The steps of each call, by `n`, until its result is in. */
1346
+ const drafts = [];
1347
+ const responseOfCall = [];
1348
+ const callsPerResponse = new Map();
1349
+ const outputPerResponse = new Map();
1350
+ const seenResponses = new Set();
1351
+ let promptFingerprint = null;
1352
+ let cur;
1353
+ // The setup, funnel, and trust shape. A subagent's run is the parent's, so it has no run of its own alone.
1354
+ const shape = createShape(harness, (tool) => storedToolName(tool, redact), { timing: origin === undefined });
1355
+ const finish = () => {
1356
+ if (cur === undefined)
1357
+ return;
1358
+ const wall = cur.t0 !== undefined && cur.t1 !== undefined && cur.t1 >= cur.t0 ? cur.t1 - cur.t0 : null;
1359
+ prompts.push({
1360
+ kind: 'user-prompt',
1361
+ cv: CAPTURE_VERSION,
1362
+ sid,
1363
+ session: input.session,
1364
+ rev: input.rev,
1365
+ index: cur.index,
1366
+ ts: cur.ts ?? null,
1367
+ wallSec: wall,
1368
+ head: cur.head,
1369
+ opened: cur.opened,
1370
+ thinking: cur.thinking,
1371
+ turns: cur.turns,
1372
+ model: majority(cur.models),
1373
+ efforts: [...cur.efforts].sort(),
1374
+ skills: [...cur.skills].sort(),
1375
+ usage: cur.usage,
1376
+ ...coverageFields(cur.coverage),
1377
+ promptFingerprint,
1378
+ });
1379
+ promptFrames.push({ version: cur.version, model: majorityOrNull(cur.models) ?? majorityOrNull(cur.messageModels), effort: majorityOrNull(cur.effortCounts), ts: cur.ts });
1380
+ };
1381
+ for (const [position, raw] of rawLines.entries()) {
1382
+ let parsed;
1383
+ try {
1384
+ parsed = JSON.parse(raw);
1385
+ }
1386
+ catch {
1387
+ session.badLines += 1;
1388
+ continue;
1389
+ }
1390
+ if (!isObj(parsed)) {
1391
+ session.badLines += 1;
1392
+ continue;
1393
+ }
1394
+ const o = parsed;
1395
+ const type = str(o.type);
1396
+ const ts = str(o.timestamp);
1397
+ // A subagent transcript marks every line a sidechain; there they are the agent's own line.
1398
+ const side = origin === undefined && truthy(o.isSidechain);
1399
+ const message = isObj(o.message) ? o.message : undefined;
1400
+ const role = message === undefined ? undefined : str(message.role);
1401
+ const content = message?.content;
1402
+ // parse_sessions.py: session-level fields from every line.
1403
+ if (ts !== undefined && ts !== '') {
1404
+ if (session.tsFirst === null || ts < session.tsFirst)
1405
+ session.tsFirst = ts;
1406
+ if (session.tsLast === null || ts > session.tsLast)
1407
+ session.tsLast = ts;
1408
+ }
1409
+ const version = str(o.version);
1410
+ if (version !== undefined && version !== '') {
1411
+ versions.add(version);
1412
+ session.env.harness = version;
1413
+ }
1414
+ const lineCwd = str(o.cwd);
1415
+ if (sessionRoot === null && lineCwd !== undefined && lineCwd !== '')
1416
+ sessionRoot = rootOf(lineCwd);
1417
+ const branch = str(o.gitBranch);
1418
+ if (branch !== undefined && branch !== '')
1419
+ branches.add(redact(branch));
1420
+ const requested = str(o.requestedModel);
1421
+ if (requested !== undefined && requested !== '') {
1422
+ session.models[requested] = (session.models[requested] ?? 0) + 1;
1423
+ bump(fileModels, requested);
1424
+ }
1425
+ // A synthetic line is the harness speaking, not a model.
1426
+ const messageModel = message === undefined || type !== 'assistant' ? undefined : str(message.model);
1427
+ const realMessageModel = messageModel === undefined || messageModel === '' || messageModel === '<synthetic>' ? undefined : messageModel;
1428
+ if (realMessageModel !== undefined)
1429
+ bump(fileMessageModels, realMessageModel);
1430
+ if (truthy(o.effort))
1431
+ bump(fileEfforts, String(o.effort));
1432
+ if (type === 'cost-state') {
1433
+ const cost = num(o.totalCostUSD);
1434
+ session.costUsd = cost ?? session.costUsd;
1435
+ }
1436
+ if (o.isCompactSummary === true)
1437
+ session.compactSummaries += 1;
1438
+ // Skeleton line: ids and structure, hashes in place of content.
1439
+ const lineUsage = message === undefined ? undefined : readUsage(message);
1440
+ const key = message === undefined ? undefined : responseKey(o, message);
1441
+ const firstOfResponse = key === undefined || !seenResponses.has(key);
1442
+ if (key !== undefined)
1443
+ seenResponses.add(key);
1444
+ const response = key ?? `line ${String(position)}`;
1445
+ if (lineUsage !== undefined && firstOfResponse)
1446
+ outputPerResponse.set(response, lineUsage.usage.output);
1447
+ const blocks = Array.isArray(content) ? content.filter(isObj) : [];
1448
+ skeletonLines.push({
1449
+ n: position + 1,
1450
+ type: type ?? null,
1451
+ uuid: str(o.uuid) ?? null,
1452
+ parentUuid: str(o.parentUuid) ?? null,
1453
+ ts: ts ?? null,
1454
+ role: role ?? null,
1455
+ tools: blocks.filter((block) => block.type === 'tool_use').map((block) => ({ id: str(block.id) ?? null, name: storedToolName(str(block.name) ?? '?', redact) })),
1456
+ results: blocks.filter((block) => block.type === 'tool_result').map((block) => str(block.tool_use_id) ?? null),
1457
+ usage: lineUsage?.usage ?? null,
1458
+ hasThinking: lineUsage?.hasThinking ?? false,
1459
+ model: (message === undefined ? undefined : str(message.model)) ?? requested ?? null,
1460
+ version: version ?? null,
1461
+ requestId: str(o.requestId) || null,
1462
+ apiBlockIndex: num(o.apiBlockIndex) ?? null,
1463
+ hashes: contentHashes(content),
1464
+ costState: type === 'cost-state' ? sumModelUsage(o.modelUsage, o.startTime) : null,
1465
+ });
1466
+ shape.read(o, { side, prompt: cur?.index ?? null });
1467
+ if (type === 'attachment') {
1468
+ const attachment = isObj(o.attachment) ? o.attachment : undefined;
1469
+ if (attachment?.type === 'prompt_snapshot' && attachment.systemPrompt !== undefined)
1470
+ promptFingerprint = sha256(JSON.stringify(attachment.systemPrompt)).slice(0, 12);
1471
+ session.promptFingerprint = promptFingerprint;
1472
+ continue;
1473
+ }
1474
+ // The harness's own copy of the request body: assistant and user messages with no cwd, uuid, or timestamp, repeating blocks the real lines
1475
+ // above already hold. Reading its tool_use, tool_result, thinking, and text blocks wrote a second row for 99.8% of calls, and a second error line,
1476
+ // thinking count, and TLDR for the same ones. The skeleton above still lists the line, so the counts and the root bind.
1477
+ if (message === undefined || type === 'api-request-blob')
1478
+ continue;
1479
+ if (type === 'user' && role === 'user' && o.isMeta !== true) {
1480
+ const text = firstNonEmptyText(content);
1481
+ if (text !== '' && !side) {
1482
+ session.userTurns += 1;
1483
+ }
1484
+ }
1485
+ if (type === 'assistant' && role === 'assistant') {
1486
+ session.assistantTurns += 1;
1487
+ if (!side && lineUsage !== undefined && firstOfResponse) {
1488
+ addUsage(session.usage, lineUsage.usage);
1489
+ addCoverage(sessionCoverage, lineUsage);
1490
+ }
1491
+ }
1492
+ // prompt_graphs.py and cost_per_class.py: a prompt is a main-line user line with text and no tool result. They also dropped any leading "<", which lost
1493
+ // every turn a command or a skill opened; that is now `promptOpening`'s call. A line from any other harness keeps the old rule.
1494
+ // A `!` shell command the operator typed, or an image they pasted with no words: neither opens a prompt (the tag is injected, the image has no text), but
1495
+ // both are the operator's turn. A line the agent did not write (a meta line, a local command, a synthetic reply) is neither the operator nor the agent.
1496
+ let operatorTurn = false;
1497
+ let harnessLine = type === 'assistant' && messageModel === '<synthetic>';
1498
+ if (type === 'user' && role === 'user' && !side && !hasToolResult(content)) {
1499
+ const leading = LEADING_TAG.exec(firstTextBlock(content).trimStart())?.[1];
1500
+ harnessLine = o.isMeta === true || (leading !== undefined && LOCAL_COMMAND_TAGS.has(leading));
1501
+ // `promptOpening` is Claude Code's alone: a prompt from any other harness that starts with `<` gets no opening below, yet it is still the operator typing, except the output of a `!` command, which is the harness's own line (an agent turn, as under Claude Code).
1502
+ operatorTurn = o.isMeta !== true && (leading === 'bash-input' || hasImage(content) || (harness !== 'claude-code' && leading !== 'bash-stdout' && leading !== 'bash-stderr' && firstTextBlock(content).startsWith('<')));
1503
+ }
1504
+ if (type === 'user' && role === 'user' && o.isMeta !== true && !side) {
1505
+ const text = firstTextBlock(content);
1506
+ const opening = text === '' || hasToolResult(content)
1507
+ ? undefined
1508
+ : harness === 'claude-code'
1509
+ ? promptOpening(text, rawLines, position, origin !== undefined)
1510
+ : text.startsWith('<')
1511
+ ? undefined
1512
+ : { opened: 'typed', request: text };
1513
+ if (opening !== undefined) {
1514
+ // The harness's interrupt marker is a line it wrote after the operator pressed Esc, not a turn the operator took.
1515
+ // A compaction summary is the harness writing the session's history back, so the agent is still working through it.
1516
+ shape.turn(text.startsWith(INTERRUPT_MARKER) ? 'none' : o.isCompactSummary === true ? 'agent' : 'human', toSeconds(ts), str(o.uuid));
1517
+ finish();
1518
+ cur = {
1519
+ index: prompts.length,
1520
+ ts,
1521
+ t0: toSeconds(ts),
1522
+ t1: undefined,
1523
+ head: cut(redact(opening.request), PROMPT_HEAD_CHARS), // redact first: a key straddling the limit would otherwise lose its tail and slip past the patterns
1524
+ opened: opening.opened,
1525
+ thinking: 0,
1526
+ models: new Map(),
1527
+ efforts: new Set(),
1528
+ skills: new Set(),
1529
+ usage: emptyUsage(),
1530
+ coverage: emptyCoverage(),
1531
+ turns: 0,
1532
+ version: version !== undefined && version !== '' ? version : undefined,
1533
+ effortCounts: new Map(),
1534
+ messageModels: new Map(),
1535
+ };
1536
+ continue;
1537
+ }
1538
+ }
1539
+ // Anything else on the main line is the agent at work, an automated turn included: only the operator's own turn ends a run alone. A line the agent did not
1540
+ // write is skipped, since it would stretch the run to a time the agent never worked until.
1541
+ if (!side && (type === 'assistant' || type === 'user'))
1542
+ shape.turn(harnessLine ? 'none' : operatorTurn ? 'human' : 'agent', toSeconds(ts), str(o.uuid));
1543
+ if (cur !== undefined) {
1544
+ // cost_per_class.py reads wall time, turns, model, and tokens from main-line lines only.
1545
+ if (!side) {
1546
+ const seconds = toSeconds(ts);
1547
+ if (ts !== undefined && ts !== '' && seconds !== undefined)
1548
+ cur.t1 = seconds;
1549
+ if (version !== undefined && version !== '')
1550
+ cur.version = version;
1551
+ if (type === 'assistant') {
1552
+ cur.turns += 1;
1553
+ if (requested !== undefined && requested !== '')
1554
+ cur.models.set(requested, (cur.models.get(requested) ?? 0) + 1);
1555
+ if (realMessageModel !== undefined)
1556
+ bump(cur.messageModels, realMessageModel);
1557
+ if (lineUsage !== undefined && firstOfResponse) {
1558
+ addUsage(cur.usage, lineUsage.usage);
1559
+ addCoverage(cur.coverage, lineUsage);
1560
+ }
1561
+ }
1562
+ }
1563
+ // prompt_graphs.py reads efforts and skills from every line.
1564
+ if (truthy(o.effort)) {
1565
+ cur.efforts.add(String(o.effort));
1566
+ if (!side)
1567
+ bump(cur.effortCounts, String(o.effort));
1568
+ }
1569
+ if (truthy(o.attributionSkill))
1570
+ cur.skills.add(String(o.attributionSkill));
1571
+ }
1572
+ if (!Array.isArray(content))
1573
+ continue;
1574
+ const lineSeconds = toSeconds(ts);
1575
+ for (const block of content) {
1576
+ if (!isObj(block))
1577
+ continue;
1578
+ const blockType = block.type;
1579
+ if (blockType === 'thinking' && cur !== undefined)
1580
+ cur.thinking += 1;
1581
+ if (blockType === 'text' && role === 'assistant') {
1582
+ const text = str(block.text) ?? '';
1583
+ for (const match of text.matchAll(TLDR_RE))
1584
+ session.tldrs.push(cut(redact(match[1] ?? ''), 400));
1585
+ for (const match of text.matchAll(CONF_RE))
1586
+ session.confidences.push(cut(redact(match[1] ?? ''), 120));
1587
+ }
1588
+ else if (blockType === 'tool_use') {
1589
+ const name = str(block.name) ?? '?';
1590
+ const id = str(block.id);
1591
+ // A resumed session replays earlier lines, so the same tool_use id comes again, often from another cwd (so a different path hash).
1592
+ // The first copy is the call and already holds the result; a replay would be a second row with status none. The skeleton
1593
+ // still lists every line, and a replayed failure stays an error line, as derive.ts counts it.
1594
+ if (id !== undefined && callById.has(id))
1595
+ continue;
1596
+ const stored = storedToolName(name, redact);
1597
+ session.tools[stored] = (session.tools[stored] ?? 0) + 1;
1598
+ toolById.set(id, stored);
1599
+ const toolInput = isObj(block.input) ? block.input : {};
1600
+ // An adapter that renames its tools for the extractor keeps the harness's own name in `native`.
1601
+ const native = str(block.native) ?? name;
1602
+ const named = actionOf(actions, native);
1603
+ const shellName = shellOf(actions, native);
1604
+ if (shellName !== null)
1605
+ bump(frameShells, shellName);
1606
+ const filePath = str(toolInput.file_path);
1607
+ const lineRoot = lineCwd === undefined || lineCwd === '' ? null : rootOf(lineCwd);
1608
+ if (PATH_TOOLS.has(name) && filePath !== undefined && filePath !== '') {
1609
+ const redacted = redact(filePath);
1610
+ framePaths.push(filePath);
1611
+ files.add(redacted);
1612
+ // One redacted path can name several files (a relative path read from two repositories), so it keeps every hash.
1613
+ fileHashes.set(redacted, (fileHashes.get(redacted) ?? new Set()).add(hashPath(filePath, lineCwd, lineRoot, salt).hash));
1614
+ }
1615
+ else if (name === 'WebFetch' && str(toolInput.url) !== undefined && str(toolInput.url) !== '')
1616
+ urls.add(redact(str(toolInput.url) ?? ''));
1617
+ else if (name === 'WebSearch' && str(toolInput.query) !== undefined && str(toolInput.query) !== '')
1618
+ searches.add(redact(str(toolInput.query) ?? ''));
1619
+ else if (SHELL.has(name))
1620
+ session.bashCmds += 1;
1621
+ const aimed = targetOf(name, toolInput, lineCwd, salt, redact, lineRoot);
1622
+ const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact) : undefined;
1623
+ for (const held of shell?.steps ?? [])
1624
+ framePrograms.add(held.identity.program);
1625
+ drafts.push(draftsOf(actions, named, native, shell, aimed.target, toolInput, redact));
1626
+ responseOfCall.push(response);
1627
+ callsPerResponse.set(response, (callsPerResponse.get(response) ?? 0) + 1);
1628
+ const manifestFile = PATH_TOOLS.has(name) && filePath !== undefined ? manifestFileOf(filePath) : null;
1629
+ const manifest = manifestFile === null ? undefined : manifestOfInput(manifestFile, name, toolInput, redact);
1630
+ if (manifestFile !== null && name === 'Read' && id !== undefined)
1631
+ manifestReads.set(id, manifestFile);
1632
+ const pulls = shell?.pulls ?? pullsOfTool(name, redact);
1633
+ const packages = packagesOf(pulls, seenPackages);
1634
+ // Who made the call, from its own line: the model is not the prompt's majority, and a line inside a skill's run names the skill (the first line after the run does not).
1635
+ const callModel = requested !== undefined && requested !== '' ? requested : realMessageModel;
1636
+ const callSkill = truthy(o.attributionSkill) ? String(o.attributionSkill) : undefined;
1637
+ const call = {
1638
+ kind: 'tool',
1639
+ cv: CAPTURE_VERSION,
1640
+ sid,
1641
+ session: input.session,
1642
+ rev: input.rev,
1643
+ parent: origin?.parent ?? input.session,
1644
+ agentType: origin?.agentType ?? null,
1645
+ n: tools.length,
1646
+ ts: ts ?? null,
1647
+ tool: stored,
1648
+ status: 'none',
1649
+ target: aimed.target,
1650
+ targetBase: aimed.base,
1651
+ keys: Object.keys(toolInput).sort(),
1652
+ // The class reads the key the target is hashed on (relative to the repository or the cwd), never the absolute path: the folders above a repository say nothing about the file.
1653
+ targetClass: targetClassOf(aimed.key, aimed.base),
1654
+ argClass: argClassOf(toolInput),
1655
+ durationMs: null,
1656
+ toolUseId: id ?? null,
1657
+ harnessVersion: version ?? null,
1658
+ ...(callModel === undefined ? {} : { model: callModel }),
1659
+ ...(callSkill === undefined ? {} : { skill: callSkill }),
1660
+ side,
1661
+ prompt: cur?.index ?? null,
1662
+ ...(shell === undefined ? {} : { segments: shell.segments }),
1663
+ ...(pulls.length === 0 ? {} : { pulls }),
1664
+ ...(packages.length === 0 ? {} : { packages }),
1665
+ ...(manifest === undefined ? {} : { manifest }),
1666
+ };
1667
+ tools.push(call);
1668
+ if (id !== undefined)
1669
+ callById.set(id, { call, at: lineSeconds, plan: shell?.commitPlan ?? [] });
1670
+ }
1671
+ else if (blockType === 'tool_result') {
1672
+ const text = resultText(block);
1673
+ const failed = isErrorResult(block, text);
1674
+ const answered = callById.get(str(block.tool_use_id) ?? '');
1675
+ if (answered !== undefined && answered.call.status === 'none') {
1676
+ const { call, at, plan } = answered;
1677
+ if (at !== undefined && lineSeconds !== undefined && o.noDuration !== true)
1678
+ call.durationMs = Math.round((lineSeconds - at) * 1000);
1679
+ call.status = failed ? 'error' : 'ok';
1680
+ if (failed) {
1681
+ call.errorCode = errorCodeOf(text);
1682
+ // An edit that did not apply changed no dependency: what its input showed is not what the file holds. A call with no result in the file (`status: 'none'`) keeps its manifest, since nothing shows it failed.
1683
+ delete call.manifest;
1684
+ }
1685
+ const read = manifestReads.get(str(block.tool_use_id) ?? '');
1686
+ const seen = read === undefined || failed ? [] : manifestNames(read, text, redact);
1687
+ if (read !== undefined && seen.length > 0)
1688
+ call.manifest = { file: read, seen, added: [], removed: [] };
1689
+ const commits = failed ? [] : commitsRead(plan, text);
1690
+ if (commits.length > 0)
1691
+ call.commits = commits;
1692
+ }
1693
+ if (!failed)
1694
+ continue;
1695
+ errorLines.push({
1696
+ kind: 'error-line',
1697
+ cv: CAPTURE_VERSION,
1698
+ sid,
1699
+ session: input.session,
1700
+ rev: input.rev,
1701
+ toolUseId: str(block.tool_use_id) ?? null,
1702
+ tool: toolById.get(str(block.tool_use_id)) ?? '?',
1703
+ // Redact first, then cut: a secret straddling the cut is masked.
1704
+ line: cut(redact(firstInformative(text)), ERROR_LINE_CHARS),
1705
+ ts: ts ?? null,
1706
+ harnessVersion: version ?? null,
1707
+ side,
1708
+ prompt: cur?.index ?? null,
1709
+ after: tools.length,
1710
+ });
1711
+ }
1712
+ }
1713
+ }
1714
+ finish();
1715
+ Object.assign(session, shape.finish(), coverageFields(sessionCoverage));
1716
+ session.versions = [...versions].sort();
1717
+ session.branches = [...branches].sort();
1718
+ const kept = [...files].sort().slice(0, FILES_PER_SESSION_MAX);
1719
+ session.files = kept;
1720
+ session.fileHashes = [...new Set(kept.flatMap((path) => [...(fileHashes.get(path) ?? [])]))].sort();
1721
+ session.repo = sessionRoot === null ? null : sha256(`${salt}\nrepo:${identityOf(sessionRoot)}`).slice(0, 16);
1722
+ session.urls = [...urls].sort();
1723
+ session.searches = [...searches].sort();
1724
+ const repoFiles = input.repoFiles ?? repoFilesFromIndex;
1725
+ // Only the first capture may look at the repository, so a frame does not follow it. The repository and `platform` are the two inputs here that are not
1726
+ // the transcript; the platform is read every time, so re-capturing the same transcript on another OS changes its `frameId`s.
1727
+ const size = input.repoSize !== undefined ? input.repoSize : sessionRoot === null ? null : repoSizeOf(repoFiles(sessionRoot));
1728
+ const stack = stackOf({ paths: framePaths, programs: [...framePrograms], size });
1729
+ const frameOf = (parts) => closedFrame({
1730
+ harness,
1731
+ harnessVersion: parts.version ?? session.env.harness,
1732
+ model: parts.model,
1733
+ effort: parts.effort,
1734
+ day: utcDay(parts.ts),
1735
+ platform: runtime.os,
1736
+ shell: majorityOrNull(frameShells),
1737
+ stack,
1738
+ });
1739
+ const sessionFrame = frameOf({ version: undefined, model: majorityOrNull(fileModels) ?? majorityOrNull(fileMessageModels), effort: majorityOrNull(fileEfforts), ts: session.tsFirst ?? undefined });
1740
+ const sessionFrameId = frameIdOf(sessionFrame);
1741
+ session.frame = sessionFrame;
1742
+ session.frameId = sessionFrameId;
1743
+ for (const [index, parts] of promptFrames.entries()) {
1744
+ const prompt = prompts[index];
1745
+ if (prompt === undefined)
1746
+ continue;
1747
+ prompt.frame = frameOf(parts);
1748
+ prompt.frameId = frameIdOf(prompt.frame);
1749
+ }
1750
+ for (const [position, call] of tools.entries()) {
1751
+ const frameId = (call.prompt === null ? undefined : prompts[call.prompt]?.frameId) ?? sessionFrameId;
1752
+ const response = responseOfCall[position];
1753
+ const output = response === undefined ? undefined : outputPerResponse.get(response);
1754
+ const tokens = response === undefined || output === undefined || output === 0 ? null : Math.round(output / (callsPerResponse.get(response) ?? 1));
1755
+ const draftsOfCall = drafts[position] ?? [];
1756
+ // A failed call of several read commands (`a && b`) says that the call failed, not which command did: the result carries one exit, so no segment is `error` on that evidence and the failure stays on the call.
1757
+ const chainFailed = call.status === 'error' && draftsOfCall.filter((draft) => draft.read).length > 1;
1758
+ const allocated = tokens !== null && response !== undefined && (callsPerResponse.get(response) ?? 1) > 1;
1759
+ call.steps = draftsOfCall.map((draft, seg) => ({
1760
+ n: call.n,
1761
+ seg,
1762
+ action: draft.action,
1763
+ native: draft.native,
1764
+ identity: draft.identity,
1765
+ status: draft.read && !chainFailed ? call.status : 'none',
1766
+ cost: seg === 0 ? { tokens, durationMs: call.durationMs, ...(allocated ? { allocated: true } : {}) } : { tokens: null, durationMs: null },
1767
+ parent: origin?.spawnedAt ?? null,
1768
+ edges: [],
1769
+ frameId,
1770
+ }));
1771
+ }
1772
+ // The strata: the file's evidence once, and each prompt's own `explore` from one pass over the calls. The size is the one the frame holds, so a re-capture that carries `repoSize` keeps `repoFiles` too.
1773
+ const packageNames = tools.flatMap((call) => [...(call.packages ?? []).map((pkg) => pkg.package), ...(call.manifest?.seen ?? []), ...(call.manifest?.added ?? [])]);
1774
+ const fileStrata = strataOf({ paths: framePaths, packages: packageNames, size });
1775
+ const exploreOfPrompt = new Map();
1776
+ let explore = 0;
1777
+ for (const call of tools) {
1778
+ const looked = exploreCount((call.steps ?? []).map((step) => step.action));
1779
+ explore += looked;
1780
+ exploreOfPrompt.set(call.prompt, (exploreOfPrompt.get(call.prompt) ?? 0) + looked);
1781
+ }
1782
+ session.strata = { ...fileStrata, explore };
1783
+ for (const [position, prompt] of prompts.entries())
1784
+ prompt.strata = { ...fileStrata, explore: exploreOfPrompt.get(position) ?? 0 };
1785
+ linkSteps(tools.map((call, position) => ({
1786
+ steps: call.steps ?? [],
1787
+ rawKeys: (drafts[position] ?? []).map((draft) => draft.rawKey),
1788
+ line: `${String(call.prompt)} ${String(call.side)}`,
1789
+ status: call.status,
1790
+ })), origin?.spawnedAt ?? null);
1791
+ return {
1792
+ session,
1793
+ prompts,
1794
+ tools,
1795
+ errorLines,
1796
+ skeleton: {
1797
+ // Written at capture from raw, never rewritten by derive, so it carries the capture stamp.
1798
+ v: CAPTURE_VERSION,
1799
+ session: input.session,
1800
+ project: input.project,
1801
+ merkleRoot: merkleRoot(rawLines.map(sha256)),
1802
+ leaves: rawLines.length,
1803
+ lines: skeletonLines,
1804
+ },
1805
+ };
1806
+ };