@manyoars/oars 0.3.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 (161) hide show
  1. package/README.md +44 -29
  2. package/dist/cli.d.ts +4 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +1 -1
  6. package/dist/gate/hook-main.js +13 -1
  7. package/dist/gate/hook-process.d.ts +11 -1
  8. package/dist/gate/hook-process.js +50 -2
  9. package/dist/gate/hook-record.d.ts +3 -0
  10. package/dist/gate/hook-record.js +8 -0
  11. package/dist/gate/hook.d.ts +2 -0
  12. package/dist/gate/hook.js +3 -0
  13. package/dist/gate/replay.js +2 -5
  14. package/dist/gate/report.js +1 -1
  15. package/dist/gate/transcripts.js +1 -1
  16. package/dist/hook-bin.js +27 -2
  17. package/dist/issuer.d.ts +7 -9
  18. package/dist/issuer.js +10 -32
  19. package/dist/main.js +3 -0
  20. package/dist/sessions/actions.js +78 -0
  21. package/dist/sessions/allow.d.ts +10 -1
  22. package/dist/sessions/allow.js +29 -9
  23. package/dist/sessions/baseline.d.ts +1 -1
  24. package/dist/sessions/baseline.js +6 -4
  25. package/dist/sessions/call-shape.d.ts +19 -0
  26. package/dist/sessions/call-shape.js +85 -0
  27. package/dist/sessions/cell-increments.d.ts +5 -1
  28. package/dist/sessions/cell-increments.js +84 -50
  29. package/dist/sessions/cells.d.ts +33 -3
  30. package/dist/sessions/cells.js +89 -9
  31. package/dist/sessions/codex.js +93 -27
  32. package/dist/sessions/command.d.ts +11 -1
  33. package/dist/sessions/command.js +64 -18
  34. package/dist/sessions/coverage.d.ts +19 -0
  35. package/dist/sessions/coverage.js +56 -0
  36. package/dist/sessions/cursor.js +2 -2
  37. package/dist/sessions/derive.js +72 -6
  38. package/dist/sessions/exclusion.d.ts +6 -0
  39. package/dist/sessions/exclusion.js +8 -0
  40. package/dist/sessions/extract.d.ts +4 -2
  41. package/dist/sessions/extract.js +58 -26
  42. package/dist/sessions/hook-calls.js +1 -1
  43. package/dist/sessions/hook-index.d.ts +14 -1
  44. package/dist/sessions/hook-index.js +10 -3
  45. package/dist/sessions/hooks.d.ts +5 -1
  46. package/dist/sessions/hooks.js +133 -22
  47. package/dist/sessions/identity.d.ts +8 -3
  48. package/dist/sessions/identity.js +45 -15
  49. package/dist/sessions/install.d.ts +18 -2
  50. package/dist/sessions/install.js +53 -8
  51. package/dist/sessions/mcp.d.ts +3 -2
  52. package/dist/sessions/mcp.js +8 -4
  53. package/dist/sessions/motif.d.ts +2 -1
  54. package/dist/sessions/motif.js +2 -2
  55. package/dist/sessions/opencode.d.ts +33 -0
  56. package/dist/sessions/opencode.js +319 -0
  57. package/dist/sessions/otel.d.ts +60 -0
  58. package/dist/sessions/otel.js +494 -0
  59. package/dist/sessions/package-books.d.ts +48 -0
  60. package/dist/sessions/package-books.js +120 -0
  61. package/dist/sessions/package-resolve.d.ts +2 -0
  62. package/dist/sessions/package-resolve.js +3 -1
  63. package/dist/sessions/pi.d.ts +36 -0
  64. package/dist/sessions/pi.js +353 -0
  65. package/dist/sessions/pool-books.d.ts +21 -0
  66. package/dist/sessions/pool-books.js +75 -0
  67. package/dist/sessions/pool-choices.d.ts +5 -0
  68. package/dist/sessions/pool-choices.js +11 -0
  69. package/dist/sessions/pool-command.d.ts +76 -5
  70. package/dist/sessions/pool-command.js +263 -136
  71. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  72. package/dist/sessions/pool-nightly-deps.js +11 -0
  73. package/dist/sessions/pool-nightly.d.ts +10 -0
  74. package/dist/sessions/pool-nightly.js +85 -0
  75. package/dist/sessions/pool-outbox.d.ts +15 -2
  76. package/dist/sessions/pool-outbox.js +79 -26
  77. package/dist/sessions/pool-print.d.ts +1 -1
  78. package/dist/sessions/pool-raw.d.ts +27 -0
  79. package/dist/sessions/pool-raw.js +68 -0
  80. package/dist/sessions/pool-replica.d.ts +35 -0
  81. package/dist/sessions/pool-replica.js +91 -0
  82. package/dist/sessions/pool-send-lock.d.ts +20 -0
  83. package/dist/sessions/pool-send-lock.js +87 -0
  84. package/dist/sessions/pool-sent.d.ts +4 -0
  85. package/dist/sessions/pool-sent.js +26 -0
  86. package/dist/sessions/pool-totals.d.ts +10 -0
  87. package/dist/sessions/pool-totals.js +21 -0
  88. package/dist/sessions/pool-wire.d.ts +240 -30
  89. package/dist/sessions/pool-wire.js +695 -215
  90. package/dist/sessions/public-vocabulary.d.ts +2 -3
  91. package/dist/sessions/public-vocabulary.js +2 -9
  92. package/dist/sessions/query.d.ts +16 -4
  93. package/dist/sessions/query.js +32 -10
  94. package/dist/sessions/read-cost.js +14 -3
  95. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  96. package/dist/sessions/read-recovery-edge.js +26 -0
  97. package/dist/sessions/read.d.ts +8 -1
  98. package/dist/sessions/read.js +6 -3
  99. package/dist/sessions/recall-bundle.d.ts +12 -0
  100. package/dist/sessions/recall-bundle.js +33 -0
  101. package/dist/sessions/recall-frames.d.ts +18 -0
  102. package/dist/sessions/recall-frames.js +44 -0
  103. package/dist/sessions/recall-next.d.ts +145 -0
  104. package/dist/sessions/recall-next.js +241 -0
  105. package/dist/sessions/recall-object.d.ts +2 -0
  106. package/dist/sessions/recall-object.js +1 -0
  107. package/dist/sessions/recall-push.d.ts +2 -2
  108. package/dist/sessions/recall-push.js +3 -51
  109. package/dist/sessions/recall-reads.d.ts +7 -0
  110. package/dist/sessions/recall-reads.js +7 -0
  111. package/dist/sessions/recovery-calls.d.ts +21 -0
  112. package/dist/sessions/recovery-calls.js +25 -0
  113. package/dist/sessions/redact.d.ts +11 -0
  114. package/dist/sessions/redact.js +59 -0
  115. package/dist/sessions/schema.d.ts +59 -9
  116. package/dist/sessions/schema.js +22 -3
  117. package/dist/sessions/session-shape.d.ts +6 -3
  118. package/dist/sessions/session-shape.js +19 -6
  119. package/dist/sessions/sources.js +75 -1
  120. package/dist/sessions/store.d.ts +13 -1
  121. package/dist/sessions/store.js +36 -16
  122. package/dist/sessions/survival.d.ts +2 -0
  123. package/dist/sessions/survival.js +2 -2
  124. package/dist/sessions/transcript-tail.d.ts +14 -0
  125. package/dist/sessions/transcript-tail.js +64 -0
  126. package/dist/sessions/vscode-copilot.js +24 -7
  127. package/dist/telemetry/command.d.ts +8 -0
  128. package/dist/telemetry/command.js +85 -0
  129. package/dist/telemetry/consent.d.ts +17 -0
  130. package/dist/telemetry/consent.js +63 -0
  131. package/dist/telemetry/crash.d.ts +8 -0
  132. package/dist/telemetry/crash.js +52 -0
  133. package/dist/telemetry/emit.d.ts +31 -0
  134. package/dist/telemetry/emit.js +42 -0
  135. package/dist/telemetry/fields.d.ts +16 -0
  136. package/dist/telemetry/fields.js +26 -0
  137. package/dist/telemetry/health.d.ts +21 -0
  138. package/dist/telemetry/health.js +108 -0
  139. package/dist/telemetry/hook-emit.d.ts +4 -0
  140. package/dist/telemetry/hook-emit.js +18 -0
  141. package/dist/telemetry/measures.d.ts +32 -0
  142. package/dist/telemetry/measures.js +128 -0
  143. package/dist/telemetry/observe.d.ts +13 -0
  144. package/dist/telemetry/observe.js +51 -0
  145. package/dist/telemetry/read-events.d.ts +9 -0
  146. package/dist/telemetry/read-events.js +17 -0
  147. package/dist/telemetry/record.d.ts +17 -0
  148. package/dist/telemetry/record.js +26 -0
  149. package/dist/telemetry/report.d.ts +77 -0
  150. package/dist/telemetry/report.js +188 -0
  151. package/dist/telemetry/schema.d.ts +40 -0
  152. package/dist/telemetry/schema.js +187 -0
  153. package/dist/telemetry/send-main.d.ts +1 -0
  154. package/dist/telemetry/send-main.js +11 -0
  155. package/dist/telemetry/send.d.ts +36 -0
  156. package/dist/telemetry/send.js +172 -0
  157. package/dist/telemetry/spool.d.ts +17 -0
  158. package/dist/telemetry/spool.js +70 -0
  159. package/dist/telemetry/wire.d.ts +25 -0
  160. package/dist/telemetry/wire.js +76 -0
  161. package/package.json +2 -2
@@ -3,7 +3,7 @@
3
3
  // version-2 extractor line for line (error_signatures.py, prompt_graphs.py, cost_per_class.py), and the recovery episodes match
4
4
  // recovery_episodes.py, so the numbers measured before the split reproduce after it.
5
5
  import { captureSession, classify, compress, cut, EDITS, EXPLORE, ACT, normalize, SHELL, shortName } from './extract.js';
6
- import { INTERRUPT_MARKER, REJECTED_RE } from './session-shape.js';
6
+ import { DECLINED_RE, INTERRUPT_MARKER, REJECTED_RE } from './session-shape.js';
7
7
  import { ERROR_EXAMPLE_CHARS, EXTRACTOR_VERSION, } from './schema.js';
8
8
  const byPosition = (a, b) => a.n - b.n;
9
9
  /** The `<tool> | <normalized line>` key; the line was redacted at capture, and normalize can expose a shape the first pass missed. */
@@ -109,9 +109,15 @@ const stoppedOf = (errors, nextHead) => {
109
109
  return 'rejected';
110
110
  return nextHead?.startsWith(INTERRUPT_MARKER) === true ? 'interrupt' : null;
111
111
  };
112
- /** The prompts whose edit a later prompt repeated. One walk in call order: a commit settles every path edited so far, so it empties the map. */
113
- const redonePrompts = (calls) => {
112
+ /** A decline in the fixed text Codex or Gemini CLI writes (`DECLINED_RE`). It is read for `intervention` only: `stopped` stays as the books and the gold arms read it. */
113
+ const declinedIn = (errors) => errors.some((error) => DECLINED_RE.test(error.line));
114
+ /**
115
+ * The prompts whose edit a later prompt repeated, and of those the ones the very next prompt repeated (`immediate`, the `redo` bucket of `intervention`). One walk in call order: a commit settles every path
116
+ * edited so far, so it empties the map. `nextOf` gives a prompt's next record.
117
+ */
118
+ const redonePrompts = (calls, nextOf) => {
114
119
  const redone = new Set();
120
+ const immediate = new Set();
115
121
  const lastEditor = new Map();
116
122
  for (const call of calls) {
117
123
  if (commitsIn(call) > 0)
@@ -120,10 +126,59 @@ const redonePrompts = (calls) => {
120
126
  const earlier = lastEditor.get(call.target);
121
127
  if (earlier !== undefined && earlier !== call.prompt && call.prompt - earlier <= REDO_WINDOW)
122
128
  redone.add(earlier);
129
+ if (earlier !== undefined && nextOf.get(earlier) === call.prompt)
130
+ immediate.add(earlier);
123
131
  lastEditor.set(call.target, call.prompt);
124
132
  }
125
133
  }
126
- return redone;
134
+ return { redone, immediate };
135
+ };
136
+ /**
137
+ * A segment that undoes work in git: a revert that names a commit (a bare `git revert` is `--abort`, `--continue`, or `--quit`, whose flag the segment drops), a restore, or a bare checkout
138
+ * (the segment drops a path, a flag, and a word with a slash or quotes, so `git checkout -- <path>` and `git checkout feature/x` are the same bare word pair; `isGitUndo` narrows it by the step's flags).
139
+ */
140
+ const GIT_UNDO = /^git (?:revert .|restore(?: |$))|^git checkout(?: \.)?$/;
141
+ const BARE_CHECKOUT = /^git checkout$/;
142
+ /** Flags of a `git checkout` that move to or create a branch, or leave one with a literal `-`: none of them undoes work. `<literal>` is also what the step makes of any flag off the public list (`--theirs`, `--patch`), so those read as no undo too: a limit, pinned in `intervention.test.ts`. */
143
+ const CHECKOUT_MOVES = new Set(['<literal>', '-b', '-B', '--orphan', '--detach']);
144
+ /** A prompt that begins by asking for the undo, in English: `revert that`, `please undo`, `rollback`. */
145
+ const ASKS_UNDO = /^(?:please )?(?:revert|undo|roll ?back)\b/i;
146
+ /** The capture version that keeps a `git checkout` step's `target` class. */
147
+ const TARGET_CLASS_CAPTURE = 23;
148
+ /** Whether the segment at index `at` is a checkout that moves to a branch: the step with the same `seg` holds the flags that the segment drops (the stored step no longer holds `run`). */
149
+ const movesBranch = (tool, at) => {
150
+ const steps = tool.steps?.filter(({ seg }) => seg === at).map(({ identity }) => identity).filter((identity) => identity !== null && 'program' in identity) ?? [];
151
+ // From CAPTURE 23 the step names the class of what the checkout acts on: a proved path is an undo whatever the flags, and an unproved word is never one. Before it, only the flags tell.
152
+ if (Number(tool.cv) >= TARGET_CLASS_CAPTURE) {
153
+ if (steps.some((identity) => identity.target === 'path'))
154
+ return false;
155
+ if (steps.some((identity) => identity.target === 'word'))
156
+ return true;
157
+ }
158
+ return steps.some((identity) => identity.flags.some((flag) => CHECKOUT_MOVES.has(flag)));
159
+ };
160
+ /** A clean shell call that undid work in git. `git reset --hard` is read from the step, since a segment keeps no flag. */
161
+ const isGitUndo = (tool) => SHELL.has(tool.tool) &&
162
+ tool.status === 'ok' &&
163
+ (tool.segments?.some(({ run }, at) => GIT_UNDO.test(run) && !(BARE_CHECKOUT.test(run) && movesBranch(tool, at))) === true ||
164
+ tool.steps?.some(({ identity }) => identity !== null && 'program' in identity && identity.program === 'git' && identity.sub === 'reset' && identity.flags.includes('--hard')) === true);
165
+ /**
166
+ * What the next prompt `next` did to the work of a prompt (docs/sessions-store.md, "Prompt intervention"). First match: the last prompt or the marker's own record is `unknown`; then a stop (`stopped` set) is `user-stop`; then `revert`, `redo`,
167
+ * `after-error`; then `unknown` again when the next prompt ran a shell call captured before `segments`, which could have been an undo; else `none`.
168
+ */
169
+ const interventionOf = (prompt, stopped, next, settled, nextCalls, immediate) => {
170
+ if (next === undefined || prompt.head.startsWith(INTERRUPT_MARKER))
171
+ return 'unknown';
172
+ // A stop is read before the others: a rejected call is settled as an error, so `after-error` would otherwise claim every rejection.
173
+ if (stopped)
174
+ return 'user-stop';
175
+ if (settled.some(isEdit) && (nextCalls.some(isGitUndo) || ASKS_UNDO.test(next.head.trimStart())))
176
+ return 'revert';
177
+ if (immediate)
178
+ return 'redo';
179
+ if (settled.at(-1)?.status === 'error')
180
+ return 'after-error';
181
+ return nextCalls.some((tool) => SHELL.has(tool.tool) && tool.segments === undefined) ? 'unknown' : 'none';
127
182
  };
128
183
  /** The version-2 prompt and error records of one transcript, with the outcome signals. */
129
184
  export const deriveSession = (capture, redact) => {
@@ -136,13 +191,21 @@ export const deriveSession = (capture, redact) => {
136
191
  const stopsOf = groupBy(firstCopies(capture.errorLines), (error) => error.prompt);
137
192
  const calls = firstCopies(tools);
138
193
  const settledOf = groupBy(calls, (tool) => tool.prompt);
139
- const redone = redonePrompts(calls);
194
+ const nextOf = new Map();
195
+ for (let at = 1; at < capture.prompts.length; at++) {
196
+ const before = capture.prompts[at - 1];
197
+ const after = capture.prompts[at];
198
+ if (before !== undefined && after !== undefined)
199
+ nextOf.set(before.index, after.index);
200
+ }
201
+ const { redone, immediate } = redonePrompts(calls, nextOf);
140
202
  const prompts = capture.prompts.map((prompt, position) => {
141
203
  const mine = toolsOf.get(prompt.index) ?? [];
142
204
  const main = mine.filter((tool) => !tool.side);
143
205
  const settled = settledOf.get(prompt.index) ?? [];
144
206
  const sequence = main.map((tool) => shortName(tool.tool));
145
207
  const errors = errorsOf.get(prompt.index) ?? [];
208
+ const stopped = stoppedOf(stopsOf.get(prompt.index) ?? [], capture.prompts[position + 1]?.head);
146
209
  // The two main-line calls after the prompt's first main-line error (version 2 re-armed on each error and kept the first two).
147
210
  const first = errors[0];
148
211
  const recovery = first === undefined ? [] : main.filter((tool) => tool.n >= first.after).slice(0, 2).map((tool) => shortName(tool.tool));
@@ -171,6 +234,8 @@ export const deriveSession = (capture, redact) => {
171
234
  efforts: prompt.efforts,
172
235
  skills: prompt.skills,
173
236
  usage: prompt.usage,
237
+ ...(prompt.usageObserved === undefined ? {} : { usageObserved: prompt.usageObserved }),
238
+ ...(prompt.usageResponses === undefined ? {} : { usageResponses: prompt.usageResponses }),
174
239
  recovery,
175
240
  explore: sequence.filter((tool) => EXPLORE.has(tool)).length,
176
241
  strata: prompt.strata,
@@ -178,9 +243,10 @@ export const deriveSession = (capture, redact) => {
178
243
  bigrams,
179
244
  contextFingerprint: prompt.promptFingerprint,
180
245
  verify: verifyOf(settled),
181
- stopped: stoppedOf(stopsOf.get(prompt.index) ?? [], capture.prompts[position + 1]?.head),
246
+ stopped,
182
247
  commits: settled.reduce((sum, tool) => sum + commitsIn(tool), 0),
183
248
  redo: redone.has(prompt.index),
249
+ intervention: interventionOf(prompt, stopped !== null || declinedIn(stopsOf.get(prompt.index) ?? []), capture.prompts[position + 1], settled, settledOf.get(capture.prompts[position + 1]?.index ?? -1) ?? [], immediate.has(prompt.index)),
184
250
  ...(prompt.frame === undefined ? {} : { frame: prompt.frame }),
185
251
  };
186
252
  });
@@ -22,6 +22,12 @@ export interface Verdict {
22
22
  source: Source;
23
23
  }[];
24
24
  }
25
+ /** The transcript files of one session, the main one first, one per path under the project folder (the harness's copy before oars' raw archive's): what a raw copy is built from, after the gate has cleared them. */
26
+ export declare const transcriptFilesOf: (runtime: SessionsRuntime, project: string, session: string) => Promise<{
27
+ key: string;
28
+ path: string;
29
+ main: boolean;
30
+ }[]>;
25
31
  /**
26
32
  * When the newest file of a session was last written, in epoch milliseconds, over the same files the gate scans (the main transcript, its subagents, and oars' raw copies), or 0 when it has none.
27
33
  * A transcript that kept growing since the last capture is newer here than in the store, so the deposit command reads it to tell a session still in progress from one that has ended.
@@ -154,6 +154,14 @@ const filesOf = async (runtime, project, session) => {
154
154
  const second = await filesIn(raw, 'raw', project, session, files);
155
155
  return { files, unreadable: unreadable || first || second };
156
156
  };
157
+ /** The transcript files of one session, the main one first, one per path under the project folder (the harness's copy before oars' raw archive's): what a raw copy is built from, after the gate has cleared them. */
158
+ export const transcriptFilesOf = async (runtime, project, session) => {
159
+ const seen = new Map();
160
+ for (const { key, path, main } of (await filesOf(runtime, project, session)).files)
161
+ if (!seen.has(key))
162
+ seen.set(key, { key, path, main });
163
+ return [...seen.values()].sort((a, b) => (a.main === b.main ? (a.key < b.key ? -1 : 1) : a.main ? -1 : 1));
164
+ };
157
165
  /** Whether any line of the file holds a term. Streamed, so a large transcript is never held whole. */
158
166
  const holdsTerm = async (path, terms) => {
159
167
  const stream = createReadStream(path, { encoding: 'utf8' });
@@ -110,6 +110,7 @@ export declare const pathKey: (path: string, cwd: string | undefined, root: stri
110
110
  export declare const hashPath: (path: string, cwd: string | undefined, root: string | null, salt: string) => {
111
111
  hash: string;
112
112
  base: TargetBase;
113
+ key: string;
113
114
  };
114
115
  /**
115
116
  * A shell call's program and subcommand from its first line (`git push`, `just build`, `npm`), as recovery_episodes.py's
@@ -144,7 +145,7 @@ export interface PlannedCommit {
144
145
  }
145
146
  export interface ShellRead {
146
147
  segments: ShellSegment[];
147
- /** The same commands as `segments`, in public names only, with the redacted text they compare by. */
148
+ /** The same commands as `segments`, in public names, apart from a pull's package name, which is kept as it is after redaction, with the redacted text they compare by. */
148
149
  steps: ShellStepRead[];
149
150
  pulls: ToolPull[];
150
151
  commitPlan: PlannedCommit[];
@@ -167,10 +168,11 @@ export declare const commitsRead: (plan: readonly PlannedCommit[], result: strin
167
168
  export declare const storedToolName: (name: string, redact: Redactor) => string;
168
169
  /** 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. */
169
170
  export declare const pullsOfTool: (name: string, redact: Redactor) => ToolPull[];
170
- /** The `target` of a tool call (see {@link ToolObservation.target}). */
171
+ /** 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. */
171
172
  export declare const targetOf: (tool: string, input: Obj, cwd: string | undefined, salt: string, redact: Redactor, root?: string | null) => {
172
173
  target: string | null;
173
174
  base: TargetBase | null;
175
+ key?: string;
174
176
  };
175
177
  /** 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. */
176
178
  export declare const callPositions: (text: string) => Map<string, number>;
@@ -7,12 +7,13 @@ import { posix } from 'node:path';
7
7
  import { ACTION_TABLES, actionOf, shellOf } from './actions.js';
8
8
  import { exploreCount, strataOf } from './strata.js';
9
9
  import { closedFrame, frameIdOf, repoFilesFromIndex, repoSizeOf, stackOf, utcDay } from './frame.js';
10
+ import { argClassOf, targetClassOf } from './call-shape.js';
10
11
  import { LITERAL } from './public-vocabulary.js';
11
12
  import { linkSteps } from './graph.js';
12
13
  import { callIdentity, shellIdentity, UNREAD_SHELL } from './identity.js';
13
14
  import { manifestDelta, manifestFileOf, manifestNames, packagesOf } from './package-resolve.js';
14
- import { createShape, INTERRUPT_MARKER } from './session-shape.js';
15
- import { CAPTURE_VERSION, ERROR_LINE_CHARS, FILES_PER_SESSION_MAX, HTTP_STATUSES, PROMPT_HEAD_CHARS, SIGNATURE_CHARS, } from './schema.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';
16
17
  export const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
17
18
  const str = (value) => (typeof value === 'string' ? value : undefined);
18
19
  const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
@@ -108,7 +109,8 @@ const EXIT_CODE_RE = /^Exit code:? (-?\d+)/;
108
109
  const HARNESS_ERROR_PATTERNS = [
109
110
  [/^\[Request interrupted by user/, 'interrupted'],
110
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.
111
- [/The user doesn't want to proceed with this tool use|The tool use was rejected|^(?:exec command|patch) rejected by user\s*$|^\[Operation Cancelled\] (?:Reason: )?(?:User (?:cancelled|denied)|Operation cancelled by user)/, 'user-rejected'],
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'],
112
114
  [/Permission to use [^\n]* has been denied/, 'permission-denied'],
113
115
  [/InputValidationError/, 'input-validation'],
114
116
  [/<tool_use_error>/, 'tool-use-error'],
@@ -191,6 +193,17 @@ const addUsage = (total, line) => {
191
193
  total.output += line.output;
192
194
  total.thinking += line.thinking;
193
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
+ });
194
207
  /** cost_per_class.py: only numeric fields count; thinking comes from `output_tokens_details`. */
195
208
  const readUsage = (message) => {
196
209
  const raw = message.usage;
@@ -198,15 +211,15 @@ const readUsage = (message) => {
198
211
  return undefined;
199
212
  const details = isObj(raw.output_tokens_details) ? raw.output_tokens_details : {};
200
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]];
201
219
  return {
202
- usage: {
203
- input: num(raw.input_tokens) ?? 0,
204
- cacheWrite: num(raw.cache_creation_input_tokens) ?? 0,
205
- cacheRead: num(raw.cache_read_input_tokens) ?? 0,
206
- output: num(raw.output_tokens) ?? 0,
207
- thinking: thinking ?? 0,
208
- },
220
+ usage: { input: input ?? 0, cacheWrite: cacheWrite ?? 0, cacheRead: cacheRead ?? 0, output: output ?? 0, thinking: thinking ?? 0 },
209
221
  hasThinking: thinking !== undefined,
222
+ observed: reported.filter(([, value]) => value !== undefined).map(([field]) => field),
210
223
  };
211
224
  };
212
225
  const toSeconds = (timestamp) => {
@@ -409,7 +422,7 @@ export const pathKey = (path, cwd, root) => {
409
422
  /** The per-account salted hash of a file path (16 hex characters, class A). */
410
423
  export const hashPath = (path, cwd, root, salt) => {
411
424
  const { key, base } = pathKey(path, cwd, root);
412
- return { hash: sha256(`${salt}\n${key}`).slice(0, 16), base };
425
+ return { hash: sha256(`${salt}\n${key}`).slice(0, 16), base, key };
413
426
  };
414
427
  /** A leading `cd <dir> &&` or `cd <dir>;` hop: the target is the command that ran after it. */
415
428
  const CD_HOP = /^\s*(?:cd|Set-Location|pushd)\s+\S+\s*(?:&&|;)\s*/;
@@ -1091,7 +1104,7 @@ export const readShell = (command, redact) => {
1091
1104
  }
1092
1105
  return {
1093
1106
  segments: kept.map((held) => ({ run: runOf(held), read: held.read })),
1094
- steps: kept.map((held) => ({ identity: shellIdentity(held.program, held.args, pullsOfCommand(held.program, held.args).filter(present)), read: held.read, run: runOf(held) })),
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) })),
1095
1108
  pulls: [...pulls.values()],
1096
1109
  commitPlan: commitPlanOf(commands),
1097
1110
  };
@@ -1196,7 +1209,7 @@ export const pullsOfTool = (name, redact) => {
1196
1209
  return pull === null ? [] : [pull];
1197
1210
  };
1198
1211
  /** A call's steps: one per command of a shell call, else one. */
1199
- const draftsOf = (table, named, native, shell, target, input) => {
1212
+ const draftsOf = (table, named, native, shell, target, input, redact) => {
1200
1213
  const { action } = named;
1201
1214
  if (action === 'shell.exec') {
1202
1215
  const read = shell?.steps ?? [];
@@ -1206,15 +1219,15 @@ const draftsOf = (table, named, native, shell, target, input) => {
1206
1219
  }
1207
1220
  const url = str(input.url);
1208
1221
  const skill = str(input.skill);
1209
- return [{ action, ...callIdentity(table, action, native, named.native, { skill, url }), read: true, rawKey: `${native}\n${target ?? url ?? skill ?? ''}` }];
1222
+ return [{ action, ...callIdentity(table, action, native, named.native, { skill, url, redact }), read: true, rawKey: `${native}\n${target ?? url ?? skill ?? ''}` }];
1210
1223
  };
1211
- /** The `target` of a tool call (see {@link ToolObservation.target}). */
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. */
1212
1225
  export const targetOf = (tool, input, cwd, salt, redact, root = null) => {
1213
1226
  for (const key of PATH_KEYS) {
1214
1227
  const value = str(input[key]);
1215
1228
  if (value !== undefined && value !== '') {
1216
- const { hash, base } = hashPath(value, cwd, root, salt);
1217
- return { target: hash, base };
1229
+ const { hash, base, key: hashedKey } = hashPath(value, cwd, root, salt);
1230
+ return { target: hash, base, key: hashedKey };
1218
1231
  }
1219
1232
  }
1220
1233
  if (!SHELL.has(tool))
@@ -1302,6 +1315,7 @@ export const captureSession = (input) => {
1302
1315
  costUsd: null,
1303
1316
  usage: emptyUsage(),
1304
1317
  };
1318
+ const sessionCoverage = emptyCoverage();
1305
1319
  const versions = new Set();
1306
1320
  const branches = new Set();
1307
1321
  const files = new Set();
@@ -1359,6 +1373,7 @@ export const captureSession = (input) => {
1359
1373
  efforts: [...cur.efforts].sort(),
1360
1374
  skills: [...cur.skills].sort(),
1361
1375
  usage: cur.usage,
1376
+ ...coverageFields(cur.coverage),
1362
1377
  promptFingerprint,
1363
1378
  });
1364
1379
  promptFrames.push({ version: cur.version, model: majorityOrNull(cur.models) ?? majorityOrNull(cur.messageModels), effort: majorityOrNull(cur.effortCounts), ts: cur.ts });
@@ -1469,8 +1484,10 @@ export const captureSession = (input) => {
1469
1484
  }
1470
1485
  if (type === 'assistant' && role === 'assistant') {
1471
1486
  session.assistantTurns += 1;
1472
- if (!side && lineUsage !== undefined && firstOfResponse)
1487
+ if (!side && lineUsage !== undefined && firstOfResponse) {
1473
1488
  addUsage(session.usage, lineUsage.usage);
1489
+ addCoverage(sessionCoverage, lineUsage);
1490
+ }
1474
1491
  }
1475
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
1476
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.
@@ -1481,8 +1498,8 @@ export const captureSession = (input) => {
1481
1498
  if (type === 'user' && role === 'user' && !side && !hasToolResult(content)) {
1482
1499
  const leading = LEADING_TAG.exec(firstTextBlock(content).trimStart())?.[1];
1483
1500
  harnessLine = o.isMeta === true || (leading !== undefined && LOCAL_COMMAND_TAGS.has(leading));
1484
- // `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.
1485
- operatorTurn = o.isMeta !== true && (leading === 'bash-input' || hasImage(content) || (harness !== 'claude-code' && firstTextBlock(content).startsWith('<')));
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('<')));
1486
1503
  }
1487
1504
  if (type === 'user' && role === 'user' && o.isMeta !== true && !side) {
1488
1505
  const text = firstTextBlock(content);
@@ -1510,6 +1527,7 @@ export const captureSession = (input) => {
1510
1527
  efforts: new Set(),
1511
1528
  skills: new Set(),
1512
1529
  usage: emptyUsage(),
1530
+ coverage: emptyCoverage(),
1513
1531
  turns: 0,
1514
1532
  version: version !== undefined && version !== '' ? version : undefined,
1515
1533
  effortCounts: new Map(),
@@ -1536,8 +1554,10 @@ export const captureSession = (input) => {
1536
1554
  cur.models.set(requested, (cur.models.get(requested) ?? 0) + 1);
1537
1555
  if (realMessageModel !== undefined)
1538
1556
  bump(cur.messageModels, realMessageModel);
1539
- if (lineUsage !== undefined && firstOfResponse)
1557
+ if (lineUsage !== undefined && firstOfResponse) {
1540
1558
  addUsage(cur.usage, lineUsage.usage);
1559
+ addCoverage(cur.coverage, lineUsage);
1560
+ }
1541
1561
  }
1542
1562
  }
1543
1563
  // prompt_graphs.py reads efforts and skills from every line.
@@ -1602,7 +1622,7 @@ export const captureSession = (input) => {
1602
1622
  const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact) : undefined;
1603
1623
  for (const held of shell?.steps ?? [])
1604
1624
  framePrograms.add(held.identity.program);
1605
- drafts.push(draftsOf(actions, named, native, shell, aimed.target, toolInput));
1625
+ drafts.push(draftsOf(actions, named, native, shell, aimed.target, toolInput, redact));
1606
1626
  responseOfCall.push(response);
1607
1627
  callsPerResponse.set(response, (callsPerResponse.get(response) ?? 0) + 1);
1608
1628
  const manifestFile = PATH_TOOLS.has(name) && filePath !== undefined ? manifestFileOf(filePath) : null;
@@ -1611,6 +1631,9 @@ export const captureSession = (input) => {
1611
1631
  manifestReads.set(id, manifestFile);
1612
1632
  const pulls = shell?.pulls ?? pullsOfTool(name, redact);
1613
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;
1614
1637
  const call = {
1615
1638
  kind: 'tool',
1616
1639
  cv: CAPTURE_VERSION,
@@ -1626,9 +1649,14 @@ export const captureSession = (input) => {
1626
1649
  target: aimed.target,
1627
1650
  targetBase: aimed.base,
1628
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),
1629
1655
  durationMs: null,
1630
1656
  toolUseId: id ?? null,
1631
1657
  harnessVersion: version ?? null,
1658
+ ...(callModel === undefined ? {} : { model: callModel }),
1659
+ ...(callSkill === undefined ? {} : { skill: callSkill }),
1632
1660
  side,
1633
1661
  prompt: cur?.index ?? null,
1634
1662
  ...(shell === undefined ? {} : { segments: shell.segments }),
@@ -1684,7 +1712,7 @@ export const captureSession = (input) => {
1684
1712
  }
1685
1713
  }
1686
1714
  finish();
1687
- Object.assign(session, shape.finish());
1715
+ Object.assign(session, shape.finish(), coverageFields(sessionCoverage));
1688
1716
  session.versions = [...versions].sort();
1689
1717
  session.branches = [...branches].sort();
1690
1718
  const kept = [...files].sort().slice(0, FILES_PER_SESSION_MAX);
@@ -1724,14 +1752,18 @@ export const captureSession = (input) => {
1724
1752
  const response = responseOfCall[position];
1725
1753
  const output = response === undefined ? undefined : outputPerResponse.get(response);
1726
1754
  const tokens = response === undefined || output === undefined || output === 0 ? null : Math.round(output / (callsPerResponse.get(response) ?? 1));
1727
- call.steps = (drafts[position] ?? []).map((draft, seg) => ({
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) => ({
1728
1760
  n: call.n,
1729
1761
  seg,
1730
1762
  action: draft.action,
1731
1763
  native: draft.native,
1732
1764
  identity: draft.identity,
1733
- status: draft.read ? call.status : 'none',
1734
- cost: seg === 0 ? { tokens, durationMs: call.durationMs } : { tokens: null, durationMs: null },
1765
+ status: draft.read && !chainFailed ? call.status : 'none',
1766
+ cost: seg === 0 ? { tokens, durationMs: call.durationMs, ...(allocated ? { allocated: true } : {}) } : { tokens: null, durationMs: null },
1735
1767
  parent: origin?.spawnedAt ?? null,
1736
1768
  edges: [],
1737
1769
  frameId,
@@ -3,7 +3,7 @@ import { appendFile, mkdir, readFile } from 'node:fs/promises';
3
3
  import { paths } from './store.js';
4
4
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
5
5
  const eventOf = (value) => {
6
- if (value === 'session-start' || value === 'pre-tool' || value === 'post-tool' || value === 'stop' || value === 'nightly')
6
+ if (value === 'session-start' || value === 'prompt' || value === 'pre-tool' || value === 'post-tool' || value === 'stop' || value === 'nightly')
7
7
  return value;
8
8
  return undefined;
9
9
  };
@@ -1,4 +1,6 @@
1
1
  import type { Read } from './read.js';
2
+ import type { Position, RecoveryEdges } from './recall-next.js';
3
+ import type { NextByFrame } from './recall-frames.js';
2
4
  import { type RecallRead } from './recall-reads.js';
3
5
  import { type Store } from './store.js';
4
6
  /** A signature is "recurring" once it has been seen in this many sessions. */
@@ -33,8 +35,19 @@ export interface HookIndex {
33
35
  fit?: {
34
36
  [readId: string]: RecallRead;
35
37
  };
38
+ /**
39
+ * The local pool's recovery edges, by the asker's frame key (`frameKeyOf`: harness and version bucket) and then `action/errorCode`. Every key is kept, one under `RECOVERY_FLOOR` too,
40
+ * so `PostToolUseFailure` can tell a cell too thin to serve (silent) from no cell (the signature line may answer). An index a version before this wrote has none.
41
+ */
42
+ recoveryByFrame?: {
43
+ [frame: string]: RecoveryEdges;
44
+ };
45
+ /** The local pool's split positions (t782's rule) by the asker's frame key, most observed first. */
46
+ positionsByFrame?: {
47
+ [frame: string]: Position[];
48
+ };
36
49
  }
37
- export declare const buildIndex: (store: Store, redactDomains: readonly string[], at: string, reads?: readonly Read[]) => HookIndex;
50
+ export declare const buildIndex: (store: Store, redactDomains: readonly string[], at: string, reads?: readonly Read[], next?: NextByFrame) => HookIndex;
38
51
  export declare const indexPath: (storeDir: string) => string;
39
52
  /** The index as written, or undefined when there is none (or it is torn): a hook then says nothing. */
40
53
  export declare const readIndex: (storeDir: string) => Promise<HookIndex | undefined>;
@@ -4,7 +4,7 @@
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { resolveReads } from './recall-pool.js';
7
- import { aboveBaseRate, fitReads, toolShapeId } from './recall-reads.js';
7
+ import { aboveBaseRate, fitReads, PUSH_FLOORS, toolShapeId } from './recall-reads.js';
8
8
  import { expandHome } from './allow.js';
9
9
  import { slugOf } from './store.js';
10
10
  const INDEX_FILE = 'hook-index.json';
@@ -33,7 +33,7 @@ const repositoryOf = (projects) => {
33
33
  return bases.find((candidate) => base === candidate || base.startsWith(`${candidate}-`)) ?? base;
34
34
  };
35
35
  };
36
- export const buildIndex = (store, redactDomains, at, reads = []) => {
36
+ export const buildIndex = (store, redactDomains, at, reads = [], next) => {
37
37
  const projectOf = new Map(store.sessions.map((session) => [session.session, session.project]));
38
38
  // The latest 300 of each repository, not of the whole store: another repository's deposits must not move this one's ranking.
39
39
  const family = repositoryOf([...new Set(store.sessions.map((session) => session.project))]);
@@ -87,7 +87,14 @@ export const buildIndex = (store, redactDomains, at, reads = []) => {
87
87
  if (rows.length > 0)
88
88
  projects[project] = rows;
89
89
  }
90
- return { at, redactDomains: [...redactDomains], projects, signatures, fit: Object.fromEntries(resolveReads(aboveBaseRate(store, fitReads(store)), reads).map((read) => [toolShapeId(read.tool, read.keys), read])) };
90
+ return {
91
+ at,
92
+ redactDomains: [...redactDomains],
93
+ projects,
94
+ signatures,
95
+ fit: Object.fromEntries(resolveReads(aboveBaseRate(store, fitReads(store, PUSH_FLOORS)), reads).map((read) => [toolShapeId(read.tool, read.keys), read])),
96
+ ...(next === undefined ? {} : { recoveryByFrame: next.recovery, positionsByFrame: next.positions }),
97
+ };
91
98
  };
92
99
  export const indexPath = (storeDir) => join(storeDir, INDEX_FILE);
93
100
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -1,13 +1,17 @@
1
+ import { type Emit } from '../telemetry/emit.js';
1
2
  import type { HookCall } from './schema.js';
2
3
  import { type SessionsRuntime } from './store.js';
4
+ import type { NightlyTelemetry } from '../telemetry/health.js';
3
5
  export type HookEvent = HookCall['event'];
4
6
  export declare const HOOK_EVENTS: readonly HookEvent[];
5
7
  /** The runtime a hook process runs with. Only `nightly` reads the non-Claude stores (a saved `--allow cursor` or `--allow codex`); the Stop path stays free of it. */
6
8
  export declare const hookRuntime: (event: HookEvent, env: {
7
9
  [name: string]: string | undefined;
8
10
  }, platform: NodeJS.Platform, home: string, io: Pick<SessionsRuntime, "stdin" | "stdout">) => SessionsRuntime;
11
+ /** What the nightly run records with, and nothing for any other hook: the telemetry folder sits beside the session store under the config directory, so `oars telemetry` and the nightly run read and write the same one. The version is read only for the nightly run, so the per-tool hooks never touch the manifest. */
12
+ export declare const nightlyTelemetry: (event: HookEvent, env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string, version: () => string, now: () => number) => NightlyTelemetry | undefined;
9
13
  /**
10
14
  * Runs one hook. It never fails or blocks the session it runs in: whatever happens, it resolves (the process exits 0), and the
11
15
  * hook-call line records the outcome, with the failure's first line when there was one, so the record is evidence, not a claim.
12
16
  */
13
- export declare const runHook: (event: HookEvent, stdin: string, runtime: SessionsRuntime, now: () => number) => Promise<string | undefined>;
17
+ export declare const runHook: (event: HookEvent, stdin: string, runtime: SessionsRuntime, now: () => number, telemetry?: NightlyTelemetry, emit?: Emit) => Promise<string | undefined>;