@manyoars/oars 0.4.1 → 0.5.2

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 (188) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +207 -21
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +260 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +613 -136
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +176 -15
  131. package/dist/sessions/pool-wire.js +297 -33
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read-interaction.js +1 -1
  139. package/dist/sessions/read-lead-lag.js +1 -1
  140. package/dist/sessions/read-readable.js +1 -1
  141. package/dist/sessions/read.d.ts +1 -1
  142. package/dist/sessions/recall-consequence.d.ts +2 -2
  143. package/dist/sessions/recall-consequence.js +14 -9
  144. package/dist/sessions/recall-object.d.ts +9 -2
  145. package/dist/sessions/receipt.d.ts +8 -0
  146. package/dist/sessions/receipt.js +78 -0
  147. package/dist/sessions/recovery-calls.d.ts +3 -2
  148. package/dist/sessions/recovery-calls.js +9 -3
  149. package/dist/sessions/redact.d.ts +11 -2
  150. package/dist/sessions/redact.js +201 -23
  151. package/dist/sessions/repo-root.d.ts +7 -0
  152. package/dist/sessions/repo-root.js +17 -13
  153. package/dist/sessions/reversion.d.ts +8 -0
  154. package/dist/sessions/reversion.js +114 -0
  155. package/dist/sessions/schema.d.ts +78 -4
  156. package/dist/sessions/schema.js +31 -1
  157. package/dist/sessions/session-shape.d.ts +2 -1
  158. package/dist/sessions/session-shape.js +15 -0
  159. package/dist/sessions/sources.d.ts +9 -0
  160. package/dist/sessions/sources.js +16 -5
  161. package/dist/sessions/steps.js +3 -2
  162. package/dist/sessions/store.d.ts +41 -2
  163. package/dist/sessions/store.js +68 -16
  164. package/dist/sessions/try-demo.d.ts +8 -0
  165. package/dist/sessions/try-demo.js +90 -0
  166. package/dist/sessions/try.d.ts +13 -0
  167. package/dist/sessions/try.js +50 -0
  168. package/dist/sessions/vscode-copilot.d.ts +4 -0
  169. package/dist/sessions/vscode-copilot.js +25 -18
  170. package/dist/telemetry/command.js +12 -6
  171. package/dist/telemetry/consent.d.ts +9 -3
  172. package/dist/telemetry/consent.js +25 -9
  173. package/dist/telemetry/fields.d.ts +11 -0
  174. package/dist/telemetry/fields.js +56 -0
  175. package/dist/telemetry/measures.d.ts +13 -0
  176. package/dist/telemetry/measures.js +30 -0
  177. package/dist/telemetry/observe.d.ts +15 -2
  178. package/dist/telemetry/observe.js +34 -7
  179. package/dist/telemetry/region.d.ts +6 -0
  180. package/dist/telemetry/region.js +28 -0
  181. package/dist/telemetry/report.js +1 -1
  182. package/dist/telemetry/schema.d.ts +3 -0
  183. package/dist/telemetry/schema.js +11 -1
  184. package/dist/telemetry/send.d.ts +5 -2
  185. package/dist/telemetry/send.js +15 -7
  186. package/dist/telemetry/words.d.ts +6 -0
  187. package/dist/telemetry/words.js +6 -0
  188. package/package.json +24 -5
@@ -7,6 +7,13 @@ import { CALL_SHAPE_METHOD, cellRef, contentHash, METHODS, RECOVERY_EDGE_METHOD
7
7
  import { recoveryCallsOf } from './recovery-calls.js';
8
8
  import { NO_RECALLS } from './recall-consequence.js';
9
9
  import { ERROR_CODES, EXTRACTOR_VERSION, harnessOf, observesUsage } from './schema.js';
10
+ /** The last field of a stored cell key. `unknown` is 0 so every key written before held existed keeps its meaning. */
11
+ export const EXPOSURE_CODES = { unknown: 0, shown: 1, held: 2 };
12
+ const statesExposure = (graph) => 'heldPrompt' in graph;
13
+ /** A cell's reading: its own `exposure`, or for a local cell the two readings it can tell apart. */
14
+ export const exposureOfCell = (cell) => cell.exposure ?? (cell.exposed ? 'shown' : 'unknown');
15
+ /** Shown wins over held, and held over unknown: a call in both windows counts as shown. */
16
+ const exposureOf = (shown, held) => (shown ? 'shown' : held ? 'held' : 'unknown');
10
17
  export const UNKNOWN = 'unknown';
11
18
  /** The class name an effort cell carries: a class motif with no prompt class in it, so a read of prompt classes skips it by this prefix. */
12
19
  export const EFFORT_CLASS = 'effort:';
@@ -33,8 +40,39 @@ export const parseRecoveryClass = (cls) => {
33
40
  const found = /^recovery:([^/]+)\/([^>/]+)>([^>/]+)$/.exec(cls);
34
41
  return found === null ? null : { code: found[1] ?? '', failed: found[2] ?? '', next: found[3] ?? '' };
35
42
  };
36
- /** Whether a class motif names a prompt class, as opposed to one of the side cells (effort, or a failed call's code or what followed it). */
37
- export const isPromptClass = (cls) => cls !== null && ![EFFORT_CLASS, ERROR_CLASS, ERROR_VERSION_CLASS, RECOVERY_CLASS].some((prefix) => cls.startsWith(prefix));
43
+ /**
44
+ * The week cell of a call's shape: `shape:<action>/<facet>/<value>`, one facet of what the call held and never a value. It keeps the harness version bucket and no model, so it joins three dimensions: the call's action, the
45
+ * facet's value, and version. `target` is the class of the path target, `arg` one member of the argument classes (a set is one cell per member, rule 2), `dropped` the `count` bucket of key names not sent, and `keys` the
46
+ * sorted key names joined by commas, the call-shape method's key (a long tail, so the pool holds it to the thresholded floor). Layer 1 adds one cell per sent key's value feature (`size`, `depth`, `extension`, `dir`, and each
47
+ * `pattern` feature, its value `<key>=<member>`), per closed value (`enum`, `<key>=<value>`), and per change kind (`change`). A class motif with no prompt class in it, like the others.
48
+ */
49
+ export const SHAPE_CLASS = 'shape:';
50
+ export const SHAPE_FACETS = ['target', 'arg', 'dropped', 'keys', 'size', 'depth', 'extension', 'dir', 'pattern', 'enum', 'change'];
51
+ export const shapeClass = (action, facet, value) => `${SHAPE_CLASS}${action}/${facet}/${value}`;
52
+ /** The three parts of a shape class name, or null for a name that is not one. An action name and a facet hold no `/`; the value is the rest. */
53
+ export const parseShapeClass = (cls) => {
54
+ const found = /^shape:([^/]+)\/([^/]+)\/(.+)$/.exec(cls);
55
+ const facet = SHAPE_FACETS.find((name) => name === found?.[2]);
56
+ return found === null || facet === undefined ? null : { action: found[1] ?? '', facet, value: found[3] ?? '' };
57
+ };
58
+ /** The facets one call counts in: each that the record holds. A record captured before a facet existed has none of it, so it adds no cell of that facet. */
59
+ export const shapeFacetsOf = (tool) => [
60
+ ...(tool.targetClass === undefined ? [] : [['target', tool.targetClass]]),
61
+ ...(tool.argClass ?? []).map((kind) => ['arg', kind]),
62
+ ...(tool.keysDropped === undefined ? [] : [['dropped', String(bucketOf('count', tool.keysDropped))]]),
63
+ ...(tool.keys.length === 0 ? [] : [['keys', [...tool.keys].sort().join(',')]]),
64
+ ...(tool.argFeatures ?? []).flatMap(({ key, valueSize, pathDepth, extension, dirKind, patternFeatures }) => [
65
+ ['size', `${key}=${valueSize}`],
66
+ ...(pathDepth === undefined ? [] : [['depth', `${key}=${pathDepth}`]]),
67
+ ...(extension === undefined ? [] : [['extension', `${key}=${extension}`]]),
68
+ ...(dirKind === undefined ? [] : [['dir', `${key}=${dirKind}`]]),
69
+ ...(patternFeatures ?? []).map((feature) => ['pattern', `${key}=${feature}`]),
70
+ ]),
71
+ ...(tool.argValues ?? []).map(({ key, enumValue }) => ['enum', `${key}=${enumValue}`]),
72
+ ...(tool.changeKind ?? []).map((kind) => ['change', kind]),
73
+ ];
74
+ /** Whether a class motif names a prompt class, as opposed to one of the side cells (effort, a failed call's code or what followed it, or a call's shape). */
75
+ export const isPromptClass = (cls) => cls !== null && ![EFFORT_CLASS, ERROR_CLASS, ERROR_VERSION_CLASS, RECOVERY_CLASS, SHAPE_CLASS].some((prefix) => cls.startsWith(prefix));
38
76
  /** The frame bucket of a cell that joins no axis. */
39
77
  export const NO_AXES = '-';
40
78
  const ONE_OPERATOR = 1;
@@ -94,7 +132,7 @@ export const cellIdOf = (cell) => {
94
132
  };
95
133
  /** The usage categories a prompt's tokens add up: thinking is part of the output already billed, so it is not summed. */
96
134
  const TOKEN_FIELDS = ['input', 'cacheWrite', 'cacheRead', 'output'];
97
- const newTally = (motifId, frameBucket, grain, day, exposed) => ({ motifId, frameBucket, grain, day, sessions: new Set(), ok: 0, failed: 0, tokens: 0, recorded: new Set(), recordedUnits: 0, durationMs: 0, exposed });
135
+ const newTally = (motifId, frameBucket, grain, day, exposure) => ({ motifId, frameBucket, grain, day, sessions: new Set(), ok: 0, failed: 0, tokens: 0, recorded: new Set(), recordedUnits: 0, durationMs: 0, exposure });
98
136
  const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
99
137
  /** A frame for a record captured before frames existed, from the fields it does hold, held to the same public vocabulary. */
100
138
  const oldFrame = (parts) => closedFrame({
@@ -125,23 +163,23 @@ const linesOf = (tools) => {
125
163
  export const deriveCells = (store, graph = NO_RECALLS) => {
126
164
  const tallies = new Map();
127
165
  const motifs = new Map();
128
- const tallyAt = (motif, frameBucket, grain, day, exposed) => {
129
- const key = `${motif.motifId}/${frameBucket}/${grain}/${day}/${String(exposed)}`;
166
+ const tallyAt = (motif, frameBucket, grain, day, exposure) => {
167
+ const key = `${motif.motifId}/${frameBucket}/${grain}/${day}/${exposure}`;
130
168
  const found = tallies.get(key);
131
169
  if (found !== undefined)
132
170
  return found;
133
- const fresh = newTally(motif.motifId, frameBucket, grain, day, exposed);
171
+ const fresh = newTally(motif.motifId, frameBucket, grain, day, exposure);
134
172
  tallies.set(key, fresh);
135
173
  return fresh;
136
174
  };
137
175
  /** The tallies one occurrence counts into: its week cell, and for a one-step shape motif with a known day its live cell too (rule 6). A window of two or more steps, a named motif, and a class motif have no day cell. */
138
- const talliesOf = (motif, frame, exposed, frameBucket = frameBucketOf(frame)) => {
176
+ const talliesOf = (motif, frame, exposure, frameBucket = frameBucketOf(frame)) => {
139
177
  motifs.set(motif.motifId, motif);
140
178
  const day = frame.day ?? UNKNOWN;
141
- const weekly = tallyAt(motif, frameBucket, 'week', weekStartOf(day), exposed);
142
- const tallies = motif.level === 'shape' && motif.steps.length === 1 && day !== UNKNOWN ? [weekly, tallyAt(motif, frameBucket, 'day', day, exposed)] : [weekly];
179
+ const weekly = tallyAt(motif, frameBucket, 'week', weekStartOf(day), exposure);
180
+ const tallies = motif.level === 'shape' && motif.steps.length === 1 && day !== UNKNOWN ? [weekly, tallyAt(motif, frameBucket, 'day', day, exposure)] : [weekly];
143
181
  // Fit's (name, platform, shell) cell: a week cell beside the versioned one, counting the same call.
144
- return isPlainCall(motif) ? [...tallies, tallyAt(motif, environmentBucketOf(frame), 'week', weekStartOf(day), exposed)] : tallies;
182
+ return isPlainCall(motif) ? [...tallies, tallyAt(motif, environmentBucketOf(frame), 'week', weekStartOf(day), exposure)] : tallies;
145
183
  };
146
184
  const sessions = new Map([...store.sessions, ...store.subagents].map((session) => [session.session, session]));
147
185
  const prompts = new Map(store.prompts.map((prompt) => [`${prompt.session}#${String(prompt.index)}`, prompt]));
@@ -161,7 +199,8 @@ export const deriveCells = (store, graph = NO_RECALLS) => {
161
199
  for (const { motif, start } of motifsOf(steps)) {
162
200
  const window = steps.slice(start, start + motif.steps.length);
163
201
  const status = window.at(-1)?.status;
164
- for (const tally of talliesOf(motif, frame, placed.slice(start, start + motif.steps.length).some(({ exposed }) => exposed))) {
202
+ const covered = placed.slice(start, start + motif.steps.length);
203
+ for (const tally of talliesOf(motif, frame, exposureOf(covered.some(({ exposed }) => exposed), covered.some((step) => 'held' in step && step.held)))) {
165
204
  tally.sessions.add(first.parent);
166
205
  if (status === 'ok')
167
206
  tally.ok += 1;
@@ -188,10 +227,24 @@ export const deriveCells = (store, graph = NO_RECALLS) => {
188
227
  const code = ERROR_CODES.has(tool.errorCode) ? tool.errorCode : 'other';
189
228
  const motif = classMotif(recoveryClass(code, calls[at]?.steps[0]?.action ?? 'other', followed.next[0] ?? NO_NEXT));
190
229
  motifs.set(motif.motifId, motif);
191
- const tally = tallyAt(motif, versionBucketOf(frame), 'week', weekStartOf(frame.day ?? UNKNOWN), false);
230
+ const tally = tallyAt(motif, versionBucketOf(frame), 'week', weekStartOf(frame.day ?? UNKNOWN), 'unknown');
192
231
  tally.sessions.add(first.parent);
193
232
  tally.failed += 1;
194
233
  }
234
+ // Each call's shape, by its first step's action: a week cell per facet it holds, counted by the call's own status.
235
+ for (const [at, tool] of line.entries()) {
236
+ const action = calls[at]?.steps[0]?.action ?? 'other';
237
+ for (const [facet, value] of shapeFacetsOf(tool)) {
238
+ const motif = classMotif(shapeClass(action, facet, value));
239
+ motifs.set(motif.motifId, motif);
240
+ const tally = tallyAt(motif, versionBucketOf(frame), 'week', weekStartOf(frame.day ?? UNKNOWN), 'unknown');
241
+ tally.sessions.add(first.parent);
242
+ if (tool.status === 'ok')
243
+ tally.ok += 1;
244
+ if (tool.status === 'error')
245
+ tally.failed += 1;
246
+ }
247
+ }
195
248
  // The failed calls by their `errorCode` (t868). Rule 6: a day cell exists for a known day only.
196
249
  const failedDay = frame.day ?? UNKNOWN;
197
250
  for (const tool of failedDay === UNKNOWN ? [] : line) {
@@ -209,7 +262,7 @@ export const deriveCells = (store, graph = NO_RECALLS) => {
209
262
  for (const [prefix, bucket] of [[ERROR_CLASS, `${frame.harness}/${NO_AXES}/${frame.model ?? UNKNOWN}`], [ERROR_VERSION_CLASS, versionBucketOf(frame)]]) {
210
263
  const motif = classMotif(`${prefix}${code}/${name}`);
211
264
  motifs.set(motif.motifId, motif);
212
- const tally = tallyAt(motif, bucket, 'day', failedDay, false);
265
+ const tally = tallyAt(motif, bucket, 'day', failedDay, 'unknown');
213
266
  tally.sessions.add(first.parent);
214
267
  tally.failed += 1;
215
268
  if (counted.tokens !== null) {
@@ -226,9 +279,9 @@ export const deriveCells = (store, graph = NO_RECALLS) => {
226
279
  for (const prompt of store.prompts) {
227
280
  const session = sessions.get(prompt.session);
228
281
  const frame = framedPrompt(prompt, harnessOf(session ?? {}), session?.env.harness ?? null, null, session?.env.os ?? UNKNOWN);
229
- const exposed = graph.exposedPrompt(prompt.session, prompt.index);
230
- const effort = frame.effort === null ? [] : talliesOf(classMotif(effortClass(frame.effort, prompt.usage.thinking)), frame, exposed, NO_AXES);
231
- for (const tally of [...talliesOf(classMotif(prompt.cls), frame, exposed), ...effort]) {
282
+ const exposure = exposureOf(graph.exposedPrompt(prompt.session, prompt.index), statesExposure(graph) && graph.heldPrompt(prompt.session, prompt.index));
283
+ const effort = frame.effort === null ? [] : talliesOf(classMotif(effortClass(frame.effort, prompt.usage.thinking)), frame, exposure, NO_AXES);
284
+ for (const tally of [...talliesOf(classMotif(prompt.cls), frame, exposure), ...effort]) {
232
285
  tally.sessions.add(prompt.session);
233
286
  if (prompt.errors > 0)
234
287
  tally.failed += 1;
@@ -245,9 +298,10 @@ export const deriveCells = (store, graph = NO_RECALLS) => {
245
298
  tally.durationMs += (prompt.wallSec ?? 0) * 1000;
246
299
  }
247
300
  }
301
+ const stated = statesExposure(graph);
248
302
  const cells = [...tallies.values()]
249
- .sort((a, b) => compare(a.motifId, b.motifId) || compare(a.frameBucket, b.frameBucket) || compare(a.grain, b.grain) || compare(a.day, b.day) || Number(a.exposed) - Number(b.exposed))
250
- .map((tally) => ({ motifId: tally.motifId, frameBucket: tally.frameBucket, grain: tally.grain, day: tally.day, sessions: tally.sessions.size, clusters: ONE_OPERATOR, ok: tally.ok, failed: tally.failed, tokens: tally.recorded.size === 0 ? null : tally.tokens, tokenSessions: tally.recorded.size, tokenPrompts: tally.recordedUnits, durationMs: tally.durationMs, exposed: tally.exposed }));
303
+ .sort((a, b) => compare(a.motifId, b.motifId) || compare(a.frameBucket, b.frameBucket) || compare(a.grain, b.grain) || compare(a.day, b.day) || EXPOSURE_CODES[a.exposure] - EXPOSURE_CODES[b.exposure])
304
+ .map((tally) => ({ motifId: tally.motifId, frameBucket: tally.frameBucket, grain: tally.grain, day: tally.day, sessions: tally.sessions.size, clusters: ONE_OPERATOR, ok: tally.ok, failed: tally.failed, tokens: tally.recorded.size === 0 ? null : tally.tokens, tokenSessions: tally.recorded.size, tokenPrompts: tally.recordedUnits, durationMs: tally.durationMs, exposed: tally.exposure === 'shown', ...(stated ? { exposure: tally.exposure } : {}) }));
251
305
  return { cells, motifs: [...motifs.values()].sort((a, b) => compare(a.motifId, b.motifId)) };
252
306
  };
253
307
  export const serializeCells = (cells) => cells.map((cell) => `${JSON.stringify(cell)}\n`).join('');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ // The entry file Codex runs for the recording hook: `node codex-hook-bin.js`, with the hook event JSON on stdin. It imports only the store modules
2
+ // (not the CLI) and always exits 0 with no output: a hook must never fail or block a session.
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { defaultConfigDir } from '../credentials.js';
6
+ import { runCodexHook } from './codex-hook.js';
7
+ import { appendOwnerOnly } from './owner-only.js';
8
+ try {
9
+ process.stdin.setEncoding('utf8');
10
+ let text = '';
11
+ for await (const chunk of process.stdin)
12
+ text += String(chunk);
13
+ const home = homedir();
14
+ await runCodexHook(text, {
15
+ storeDir: join(defaultConfigDir(process.env, process.platform, home), 'oars', 'sessions'),
16
+ home,
17
+ now: Date.now,
18
+ append: appendOwnerOnly,
19
+ });
20
+ }
21
+ catch {
22
+ // Fail-open.
23
+ }
24
+ process.exitCode = 0;
@@ -0,0 +1,21 @@
1
+ /** What the install needs from the machine: the Codex home (`codexRoot`), the node binary, and the built `codex-hook-bin.js`. */
2
+ export interface CodexInstall {
3
+ home: string;
4
+ nodePath: string;
5
+ hookBin: string;
6
+ }
7
+ export declare const codexHooksFile: (codex: CodexInstall) => string;
8
+ /** The line the operator reads after an install: Codex does not run the entry until it is trusted. */
9
+ export declare const CODEX_TRUST_NOTICE = "Codex asks you to review and trust a new hook before it runs it; until you do, nothing is recorded. Rows go to hook-rows.jsonl in the oars store, only while codex is allowed, and never leave this machine.";
10
+ export declare const installCodexHook: (codex: CodexInstall) => Promise<{
11
+ file: string;
12
+ installed: string[];
13
+ }>;
14
+ export declare const uninstallCodexHook: (codex: CodexInstall) => Promise<{
15
+ file: string;
16
+ removed: string[];
17
+ }>;
18
+ export declare const codexHookStatus: (codex: CodexInstall) => Promise<{
19
+ file: string;
20
+ installed: string[];
21
+ }>;
@@ -0,0 +1,47 @@
1
+ // `oars sessions hook install --harness codex`: writes the recording hook's entries into `hooks.json` in the Codex home, merging with what is there.
2
+ // An entry is ours when its command ends with `codex-hook-bin.js"`, so uninstall removes those and nothing else. Nothing here runs on upgrade or on first
3
+ // run, and nothing writes the trust Codex asks for: a non-managed hook is skipped until the operator reviews it in Codex.
4
+ import { join } from 'node:path';
5
+ import { CODEX_HOOK_EVENTS } from './codex-hook.js';
6
+ import { groupsOf, hasOwn, hooksOf, readSettings, withoutOwn, writeSettings } from './install.js';
7
+ const OWN_COMMAND = /codex-hook-bin\.js"$/;
8
+ /** Seconds Codex waits; the hook appends one line and exits, so this is only a ceiling. */
9
+ const TIMEOUT = 5;
10
+ export const codexHooksFile = (codex) => join(codex.home, 'hooks.json');
11
+ /** The line the operator reads after an install: Codex does not run the entry until it is trusted. */
12
+ export const CODEX_TRUST_NOTICE = 'Codex asks you to review and trust a new hook before it runs it; until you do, nothing is recorded. Rows go to hook-rows.jsonl in the oars store, only while codex is allowed, and never leave this machine.';
13
+ export const installCodexHook = async (codex) => {
14
+ const file = codexHooksFile(codex);
15
+ const settings = await readSettings(file);
16
+ const hooks = hooksOf(settings);
17
+ for (const event of CODEX_HOOK_EVENTS) {
18
+ const entry = { type: 'command', command: `"${codex.nodePath}" "${codex.hookBin}"`, timeout: TIMEOUT };
19
+ hooks[event] = [...withoutOwn(groupsOf(hooks, event), OWN_COMMAND), { hooks: [entry] }];
20
+ }
21
+ settings.hooks = hooks;
22
+ await writeSettings(file, settings);
23
+ return { file, installed: [...CODEX_HOOK_EVENTS] };
24
+ };
25
+ export const uninstallCodexHook = async (codex) => {
26
+ const file = codexHooksFile(codex);
27
+ const settings = await readSettings(file);
28
+ const hooks = hooksOf(settings);
29
+ const removed = CODEX_HOOK_EVENTS.filter((event) => hasOwn(groupsOf(hooks, event), OWN_COMMAND));
30
+ if (removed.length === 0)
31
+ return { file, removed: [] };
32
+ for (const event of removed) {
33
+ const kept = withoutOwn(groupsOf(hooks, event), OWN_COMMAND);
34
+ if (kept.length === 0)
35
+ Reflect.deleteProperty(hooks, event);
36
+ else
37
+ hooks[event] = kept;
38
+ }
39
+ settings.hooks = Object.keys(hooks).length === 0 ? undefined : hooks;
40
+ await writeSettings(file, settings);
41
+ return { file, removed: [...removed] };
42
+ };
43
+ export const codexHookStatus = async (codex) => {
44
+ const file = codexHooksFile(codex);
45
+ const hooks = hooksOf(await readSettings(file));
46
+ return { file, installed: CODEX_HOOK_EVENTS.filter((event) => hasOwn(groupsOf(hooks, event), OWN_COMMAND)) };
47
+ };
@@ -0,0 +1,20 @@
1
+ import type { HookRow } from './codex-hook.js';
2
+ /** `beside`: the reader holds this rollout, so its record stands and these rows are shown next to it. `alone`: the reader holds none, so the rows stand by themselves and are degraded. */
3
+ export type Standing = 'beside' | 'alone';
4
+ export interface HookGroup {
5
+ /** The thread the rows name; null when the payload carried none. */
6
+ session: string | null;
7
+ /** Set for a subagent's rows, which carry the parent's session id. */
8
+ agentId: string | null;
9
+ standing: Standing;
10
+ /** True when the rows stand alone, so the record they make is the degraded one. */
11
+ degraded: boolean;
12
+ /** The rows as written, in order: not copied into a reader record, and not counted as one. */
13
+ rows: readonly HookRow[];
14
+ }
15
+ /**
16
+ * Groups `rows` by the thread they name (and by subagent, when they name one). A subagent's row carries the parent's session id, so keying it to the
17
+ * parent's rollout would fold one agent's calls into another's record: it always stands alone. `readerSessions` holds the session ids of the rollouts
18
+ * the reader can read; a rollout it refuses is simply absent from it.
19
+ */
20
+ export declare const separateHookRows: (rows: readonly HookRow[], readerSessions: ReadonlySet<string>) => HookGroup[];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Groups `rows` by the thread they name (and by subagent, when they name one). A subagent's row carries the parent's session id, so keying it to the
3
+ * parent's rollout would fold one agent's calls into another's record: it always stands alone. `readerSessions` holds the session ids of the rollouts
4
+ * the reader can read; a rollout it refuses is simply absent from it.
5
+ */
6
+ export const separateHookRows = (rows, readerSessions) => {
7
+ const groups = new Map();
8
+ for (const row of rows) {
9
+ const key = JSON.stringify([row.session, row.agentId]);
10
+ const group = groups.get(key);
11
+ if (group === undefined)
12
+ groups.set(key, { session: row.session, agentId: row.agentId, rows: [row] });
13
+ else
14
+ group.rows.push(row);
15
+ }
16
+ return [...groups.values()].map((group) => {
17
+ const beside = group.agentId === null && group.session !== null && readerSessions.has(group.session);
18
+ return { ...group, standing: beside ? 'beside' : 'alone', degraded: !beside };
19
+ });
20
+ };
@@ -0,0 +1,77 @@
1
+ import { type CanonicalAction } from './actions.js';
2
+ import { type ArgClass } from './call-shape.js';
3
+ /** The events the installed entry registers, as Codex names them. */
4
+ export declare const CODEX_HOOK_EVENTS: readonly ["SessionStart", "PostToolUse", "PostModelRequest"];
5
+ export declare const ERROR_FLAGS: readonly ["yes", "no", "unknown"];
6
+ export type ErrorFlag = (typeof ERROR_FLAGS)[number];
7
+ export declare const CWD_CLASSES: readonly ["repo", "home", "other", "none"];
8
+ export type CwdClass = (typeof CWD_CLASSES)[number];
9
+ export declare const RESULT_KINDS: readonly ["string", "object", "array", "null", "absent"];
10
+ export type ResultKind = (typeof RESULT_KINDS)[number];
11
+ /** The counters of one model request: a number the payload gave, else null. */
12
+ export interface HookUsage {
13
+ input: number | null;
14
+ cacheRead: number | null;
15
+ cacheWrite: number | null;
16
+ output: number | null;
17
+ thinking: number | null;
18
+ total: number | null;
19
+ }
20
+ /** One line of `hook-rows.jsonl`. Which fields are set depends on `kind`; `degraded` is true when the payload lacked a field the kind needs, and `missing` names those fields. */
21
+ export interface HookRow {
22
+ v: 1;
23
+ source: 'hook';
24
+ harness: 'codex';
25
+ kind: 'session' | 'tool' | 'model';
26
+ at: string;
27
+ session: string | null;
28
+ turn: string | null;
29
+ toolUseId: string | null;
30
+ /** Set only when the payload names a subagent: such a row carries the parent's session id and is never keyed to the parent thread's rollout. */
31
+ agentId: string | null;
32
+ action: CanonicalAction | null;
33
+ tool: string | null;
34
+ argClass: ArgClass[] | null;
35
+ resultKind: ResultKind | null;
36
+ error: ErrorFlag | null;
37
+ usage: HookUsage | null;
38
+ model: string | null;
39
+ cwdClass: CwdClass | null;
40
+ degraded: boolean;
41
+ missing: string[];
42
+ }
43
+ type Obj = {
44
+ [key: string]: unknown;
45
+ };
46
+ /** `unknown` unless the header Codex writes before the `Output:` line carries an exit line: the command's own text decides nothing, and a response with no such header says nothing. */
47
+ export declare const errorFlagOf: (response: unknown) => ErrorFlag;
48
+ /** The row for one hook payload, or undefined for an event this hook does not record. */
49
+ export declare const hookRowOf: (payload: Obj, at: string, home?: string, repoRoot?: (cwd: string) => string | null) => HookRow | undefined;
50
+ /**
51
+ * The saved `--allow` and `--deny` list, read strictly: undefined when `state.json` exists and cannot be read as a list of strings. Deposit
52
+ * treats such a file as an empty list, which allows every source; a hook that did the same would record under a `--deny codex` it lost.
53
+ */
54
+ export declare const savedList: (storeDir: string) => Promise<string[] | undefined>;
55
+ /**
56
+ * Whether a row may be written: the saved list allows the `codex` source (the gate deposit applies), and the payload's folder is not one a
57
+ * saved `--deny` names. The folder test uses the denials alone, because an `--allow codex` entry names no folder and would match none.
58
+ * A payload with no folder cannot be tested, so it records only when the list holds no denial.
59
+ */
60
+ export declare const mayRecord: (saved: readonly string[] | undefined, cwd: string | null) => boolean;
61
+ /** What the hook needs from the machine, so a test can stand in for each. */
62
+ export interface CodexHookRuntime {
63
+ storeDir: string;
64
+ home: string;
65
+ now: () => number;
66
+ /** The lockless append; a failure reaches the note in `hook-calls.jsonl` and no partial row is left. */
67
+ append: (file: string, text: string) => Promise<void>;
68
+ /** The repo-root lookup for the folder class; a test stands in for it. */
69
+ repoRoot?: (cwd: string) => string | null;
70
+ }
71
+ /**
72
+ * Runs one hook call. It resolves whatever happens, prints nothing, and takes no lock. The row is built whole and written with one append, so a
73
+ * failure before or during the write leaves no partial row. A call the consent gate refuses leaves no trace at all; one that wrote a row, or
74
+ * failed, leaves a note in `hook-calls.jsonl` (the failure's first line, masked).
75
+ */
76
+ export declare const runCodexHook: (stdin: string, runtime: CodexHookRuntime) => Promise<void>;
77
+ export {};
@@ -0,0 +1,255 @@
1
+ // The Codex recording hook: when the operator installs it (`oars sessions hook install --harness codex`), Codex runs it after each tool call and
2
+ // each model request, and it appends one closed-vocabulary row to `hook-rows.jsonl`. A row is a separately marked observation (`source: 'hook'`):
3
+ // it is never merged into a record the store reader (`codex.ts`) made from the rollout, so a Codex release that changes the rollout format
4
+ // degrades this record instead of stopping it. No prompt, command, output, or path enters a row; the payload's text is read here and goes no further.
5
+ // Fail-open: the hook prints nothing, takes no lock, and exits 0 on every error, leaving a note in `hook-calls.jsonl`.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import { ACTION_TABLES, actionOf } from './actions.js';
9
+ import { compileAllow, isDenial, slugOf } from './allow.js';
10
+ import { argClassOf } from './call-shape.js';
11
+ import { appendHookCall } from './hook-calls.js';
12
+ import { mkdirOwnerOnly } from './owner-only.js';
13
+ import { makeRepoRoot } from './repo-root.js';
14
+ import { errorLine, tryLog } from './steps.js';
15
+ import { paths } from './store.js';
16
+ // The names the hook payload gives a tool that the rollout's table (`ACTION_TABLES.codex`) does not list: the hook contract says `Bash` and `apply_patch` carry `tool_input.command`.
17
+ const HOOK_TOOLS = new Map([['Bash', 'shell.exec']]);
18
+ /** The events the installed entry registers, as Codex names them. */
19
+ export const CODEX_HOOK_EVENTS = ['SessionStart', 'PostToolUse', 'PostModelRequest'];
20
+ export const ERROR_FLAGS = ['yes', 'no', 'unknown'];
21
+ export const CWD_CLASSES = ['repo', 'home', 'other', 'none'];
22
+ export const RESULT_KINDS = ['string', 'object', 'array', 'null', 'absent'];
23
+ const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ const str = (value) => (typeof value === 'string' && value !== '' ? value : null);
25
+ const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
26
+ const MODEL_NAME = /^[A-Za-z0-9._:/-]{1,64}$/;
27
+ // A model configured by path (`/models/x.gguf`, `./x`, `~/x`, `C:/x`, `a/b/c`) names a place on the machine, which no row may hold.
28
+ const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)|\/.*\//;
29
+ /** A model name as a closed shape: the name when it is plain, else `other`. An `org/model` name is plain; a path is not. */
30
+ const modelOf = (value) => {
31
+ const name = str(value);
32
+ return name === null ? null : MODEL_NAME.test(name) && !PATH_SHAPED.test(name) ? name : 'other';
33
+ };
34
+ // The three spellings Codex gives a finished command's outcome (`Exit code: N`, `Process exited with code N`, `exit code N`), at the start of a line.
35
+ const EXIT_LINE = /^[ \t]*(?:exit code:?[ \t]*|process exited with code[ \t]+)(-?\d+)[ \t]*$/im;
36
+ // The line a command's output begins after: the header above it is Codex's, the text below is whatever the command printed.
37
+ const OUTPUT_MARK = /(?:^|\n)Output:\n?/;
38
+ /** `unknown` unless the header Codex writes before the `Output:` line carries an exit line: the command's own text decides nothing, and a response with no such header says nothing. */
39
+ export const errorFlagOf = (response) => {
40
+ if (typeof response !== 'string')
41
+ return 'unknown';
42
+ const mark = OUTPUT_MARK.exec(response);
43
+ if (mark === null)
44
+ return 'unknown';
45
+ const code = EXIT_LINE.exec(response.slice(0, mark.index))?.[1];
46
+ return code === undefined ? 'unknown' : Number(code) === 0 ? 'no' : 'yes';
47
+ };
48
+ const resultKindOf = (response) => {
49
+ if (response === undefined)
50
+ return 'absent';
51
+ if (response === null)
52
+ return 'null';
53
+ if (Array.isArray(response))
54
+ return 'array';
55
+ return typeof response === 'string' ? 'string' : 'object';
56
+ };
57
+ const USAGE_KEYS = [
58
+ ['input', 'input_tokens'],
59
+ ['cacheRead', 'cached_input_tokens'],
60
+ ['cacheWrite', 'cache_write_input_tokens'],
61
+ ['output', 'output_tokens'],
62
+ ['thinking', 'reasoning_output_tokens'],
63
+ ['total', 'total_tokens'],
64
+ ];
65
+ const blank = (kind, at, payload) => ({
66
+ v: 1,
67
+ source: 'hook',
68
+ harness: 'codex',
69
+ kind,
70
+ at,
71
+ session: str(payload.session_id),
72
+ turn: str(payload.turn_id),
73
+ toolUseId: null,
74
+ agentId: str(payload.agent_id),
75
+ action: null,
76
+ tool: null,
77
+ argClass: null,
78
+ resultKind: null,
79
+ error: null,
80
+ usage: null,
81
+ model: null,
82
+ cwdClass: null,
83
+ degraded: false,
84
+ missing: [],
85
+ });
86
+ /** What one PostToolUse payload becomes. A field the payload lacks is named in `missing`; one it adds is ignored. */
87
+ const toolRow = (at, payload) => {
88
+ const row = blank('tool', at, payload);
89
+ const name = str(payload.tool_name);
90
+ const aliased = name === null ? undefined : HOOK_TOOLS.get(name);
91
+ const found = name === null ? undefined : aliased === undefined ? actionOf(ACTION_TABLES.codex, name) : { action: aliased, native: name };
92
+ row.toolUseId = str(payload.tool_use_id);
93
+ row.action = found?.action ?? null;
94
+ row.tool = found?.native ?? null;
95
+ row.argClass = isObj(payload.tool_input) ? argClassOf(payload.tool_input) : null;
96
+ row.resultKind = resultKindOf(payload.tool_response);
97
+ row.error = errorFlagOf(payload.tool_response);
98
+ row.missing = [
99
+ ...(row.session === null ? ['session_id'] : []),
100
+ ...(row.turn === null ? ['turn_id'] : []),
101
+ ...(row.toolUseId === null ? ['tool_use_id'] : []),
102
+ ...(name === null ? ['tool_name'] : []),
103
+ ...(row.argClass === null ? ['tool_input'] : []),
104
+ ...(payload.tool_response === undefined ? ['tool_response'] : []),
105
+ ];
106
+ return row;
107
+ };
108
+ /** What one PostModelRequest payload becomes. `token_usage` is null on a failed or cancelled attempt: the row is then degraded, and carries no counts. */
109
+ const modelRow = (at, payload) => {
110
+ const row = blank('model', at, payload);
111
+ row.model = modelOf(payload.model);
112
+ const usage = payload.token_usage;
113
+ const missing = [...(row.session === null ? ['session_id'] : []), ...(row.turn === null ? ['turn_id'] : [])];
114
+ if (isObj(usage)) {
115
+ const counters = { input: null, cacheRead: null, cacheWrite: null, output: null, thinking: null, total: null };
116
+ row.usage = counters;
117
+ for (const [field, key] of USAGE_KEYS) {
118
+ counters[field] = count(usage[key]);
119
+ if (counters[field] === null)
120
+ missing.push(`token_usage.${key}`);
121
+ }
122
+ }
123
+ else
124
+ missing.push('token_usage');
125
+ row.missing = missing;
126
+ return row;
127
+ };
128
+ /** What the working folder is, as a class and never a path: inside a repository, the home folder itself, anywhere else, or not given. */
129
+ const cwdClassOf = (cwd, home, repoRoot) => {
130
+ if (cwd === null)
131
+ return 'none';
132
+ if (slugOf(cwd) === slugOf(home))
133
+ return 'home';
134
+ return repoRoot(cwd) === null ? 'other' : 'repo';
135
+ };
136
+ const sessionRow = (at, payload, home, repoRoot) => {
137
+ const row = blank('session', at, payload);
138
+ row.model = modelOf(payload.model);
139
+ row.cwdClass = cwdClassOf(str(payload.cwd), home, repoRoot);
140
+ row.missing = [...(row.session === null ? ['session_id'] : []), ...(row.model === null ? ['model'] : []), ...(row.cwdClass === 'none' ? ['cwd'] : [])];
141
+ return row;
142
+ };
143
+ /** The row for one hook payload, or undefined for an event this hook does not record. */
144
+ export const hookRowOf = (payload, at, home = homedir(), repoRoot = makeRepoRoot()) => {
145
+ const event = payload.hook_event_name;
146
+ const row = event === 'PostToolUse' ? toolRow(at, payload) : event === 'PostModelRequest' ? modelRow(at, payload) : event === 'SessionStart' ? sessionRow(at, payload, home, repoRoot) : undefined;
147
+ if (row !== undefined)
148
+ row.degraded = row.missing.length > 0;
149
+ return row;
150
+ };
151
+ /**
152
+ * The saved `--allow` and `--deny` list, read strictly: undefined when `state.json` exists and cannot be read as a list of strings. Deposit
153
+ * treats such a file as an empty list, which allows every source; a hook that did the same would record under a `--deny codex` it lost.
154
+ */
155
+ export const savedList = async (storeDir) => {
156
+ let text;
157
+ try {
158
+ text = await readFile(paths(storeDir).state, 'utf8');
159
+ }
160
+ catch (error) {
161
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? [] : undefined;
162
+ }
163
+ try {
164
+ const state = JSON.parse(text);
165
+ if (!isObj(state))
166
+ return undefined;
167
+ const list = state.allow;
168
+ if (list === undefined)
169
+ return [];
170
+ return Array.isArray(list) && list.every((entry) => typeof entry === 'string') ? list : undefined;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ };
176
+ /**
177
+ * Whether a row may be written: the saved list allows the `codex` source (the gate deposit applies), and the payload's folder is not one a
178
+ * saved `--deny` names. The folder test uses the denials alone, because an `--allow codex` entry names no folder and would match none.
179
+ * A payload with no folder cannot be tested, so it records only when the list holds no denial.
180
+ */
181
+ export const mayRecord = (saved, cwd) => {
182
+ if (saved === undefined || !compileAllow(saved).allows('codex'))
183
+ return false;
184
+ const denials = saved.filter(isDenial);
185
+ if (denials.length === 0)
186
+ return true;
187
+ return cwd !== null && compileAllow(denials).allows(slugOf(cwd));
188
+ };
189
+ /**
190
+ * Reduces every absolute path in a note to `<path>`: a POSIX path, a drive path, or a UNC path, whether or not it sits under the home
191
+ * folder (a failure's message can name any folder, and no path enters a row or a note). Three passes, widest first: a quoted run that starts
192
+ * with a path is masked whole, to the last matching quote on the line and with the quotes kept, so a space or an apostrophe in it leaves no
193
+ * tail; the hook's own home, matched in either separator and any case like the store's home scrub, goes with the rest of its token; then
194
+ * any bare path token.
195
+ */
196
+ const TOKEN_TAIL = /[^\s'"`,;)]*/.source;
197
+ const maskPaths = (text, home) => {
198
+ const quoted = text.replace(/(['"`])(?:[A-Za-z]:[\\/]|\\\\|\/)[^\n]*\1/g, '$1<path>$1');
199
+ const pattern = home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/(?:\\\\|\/)/g, '[\\\\/]');
200
+ const own = home === '' ? quoted : quoted.replace(new RegExp(pattern + TOKEN_TAIL, 'gi'), '<path>');
201
+ return own.replace(/(?:[A-Za-z]:[\\/]|\\\\|(?<![\w.~<>-])\/)[^\s'"`,;)]*/g, '<path>');
202
+ };
203
+ // JSON.parse's message quotes the input, and the note must hold none of it, so the failure is a fixed line.
204
+ const parse = (text) => {
205
+ let parsed;
206
+ try {
207
+ parsed = JSON.parse(text);
208
+ }
209
+ catch {
210
+ throw new Error('The hook input is not JSON.');
211
+ }
212
+ if (!isObj(parsed))
213
+ throw new Error('The hook input is not an object.');
214
+ return parsed;
215
+ };
216
+ /**
217
+ * Runs one hook call. It resolves whatever happens, prints nothing, and takes no lock. The row is built whole and written with one append, so a
218
+ * failure before or during the write leaves no partial row. A call the consent gate refuses leaves no trace at all; one that wrote a row, or
219
+ * failed, leaves a note in `hook-calls.jsonl` (the failure's first line, masked).
220
+ */
221
+ export const runCodexHook = async (stdin, runtime) => {
222
+ const started = performance.now();
223
+ let session = null;
224
+ let wrote = false;
225
+ let failure;
226
+ let failed = false;
227
+ try {
228
+ const payload = parse(stdin);
229
+ session = str(payload.session_id);
230
+ // The gate comes first, so a denied folder is never walked for its repo root.
231
+ if (mayRecord(await savedList(runtime.storeDir), str(payload.cwd))) {
232
+ const row = hookRowOf(payload, new Date(runtime.now()).toISOString(), runtime.home, runtime.repoRoot);
233
+ if (row !== undefined) {
234
+ const line = `${JSON.stringify(row)}\n`;
235
+ await mkdirOwnerOnly(runtime.storeDir);
236
+ await runtime.append(paths(runtime.storeDir).hookRows, line);
237
+ wrote = true;
238
+ }
239
+ }
240
+ }
241
+ catch (thrown) {
242
+ failed = true;
243
+ failure = thrown;
244
+ }
245
+ if (!wrote && !failed)
246
+ return;
247
+ const ms = Math.round(performance.now() - started);
248
+ // Built only for a failure, and inside the guard, so a message that is not a string cannot throw past the note.
249
+ const error = failed
250
+ ? await Promise.resolve()
251
+ .then(() => errorLine(runtime.storeDir, new Error(maskPaths(failure instanceof Error ? failure.message : String(failure), runtime.home))))
252
+ .catch(() => 'unknown error')
253
+ : null;
254
+ await tryLog(() => appendHookCall(runtime.storeDir, { at: new Date(runtime.now()).toISOString(), event: 'codex-record', session, hit: wrote, ms, error }));
255
+ };