opencode-turnstile 0.2.1 → 0.2.5

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.
package/src/config.ts CHANGED
@@ -8,13 +8,21 @@
8
8
  * contract.
9
9
  */
10
10
 
11
+ import { createHash } from "node:crypto"
11
12
  import * as fs from "node:fs"
12
13
  import * as os from "node:os"
13
14
  import * as path from "node:path"
14
15
 
15
16
  import { appendLine, defaultMetricsDir } from "./journal.ts"
16
17
  import { GATE_RE, VERDICT_RE } from "./markers.ts"
17
- import type { TurnstileConfig } from "./types.ts"
18
+ import type {
19
+ GateEvidenceMode,
20
+ TurnstileAgents,
21
+ TurnstileConfig,
22
+ TurnstileGitGates,
23
+ TurnstileToolPolicy,
24
+ ToolPolicyMode,
25
+ } from "./types.ts"
18
26
 
19
27
  /** Default config file location. */
20
28
  export const CONFIG_PATH: string = path.join(os.homedir(), ".config", "opencode", "turnstile.json")
@@ -26,6 +34,26 @@ export const ENV_KEYS = {
26
34
  config: "TURNSTILE_CONFIG",
27
35
  } as const
28
36
 
37
+ /**
38
+ * Default case-insensitive evidence pattern: plausible test commands across
39
+ * the common JS and non-JS toolchains. Compiled with `"i"`; a host can override
40
+ * `evidencePattern` for toolchains the default does not cover.
41
+ */
42
+ export const DEFAULT_EVIDENCE_PATTERN =
43
+ "\\b(npm\\s+(run\\s+)?test|pnpm\\s+test|yarn\\s+test|bun\\s+test|deno\\s+test|node\\s+--test|npx\\s+node\\s+--test|pytest|make\\s+test(\\s|$)|go\\s+test|cargo\\s+test)\\b"
44
+
45
+ /**
46
+ * Default conventional-commits regex SOURCE string for `gitGates.commitFormat`
47
+ * (compiled by callers with `new RegExp("^" + source + "$", "s")`). It accepts
48
+ * a single type from the conventional set `{feat,fix,docs,style,refactor,perf,
49
+ * test,build,ci,chore,revert}`, an optional `(scope)`, a required `!` or `:`
50
+ * separator (only one is needed — no `BREAKING CHANGE` trailer), a subject of
51
+ * up to 72 characters on the first line, and one optional following body line.
52
+ * Note the source carries no leading `^`/trailing `$`: the caller wraps them.
53
+ */
54
+ export const DEFAULT_COMMIT_FORMAT =
55
+ "(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\([^)]*\\))?(?:[!]:?|:) [^\\r\\n]{1,72}(?:(?:\\r?\\n){1,2}[^\\r\\n]*)?"
56
+
29
57
  /**
30
58
  * Builds the code defaults for every config key.
31
59
  * @param env Environment consulted for XDG_DATA_HOME only.
@@ -44,9 +72,11 @@ export function defaultConfig(
44
72
  readWarn: 8,
45
73
  readBlock: 15,
46
74
  waiverMarker: "USER WAIVER:",
75
+ approvalMarker: "PLAN APPROVED:",
47
76
  plansDir: path.join(".opencode", "plans"),
48
77
  trace: true,
49
78
  agents: {
79
+ planner: "planner",
50
80
  reviewer: "reviewer",
51
81
  planReviewer: "plan-reviewer",
52
82
  executor: "executor",
@@ -56,6 +86,41 @@ export function defaultConfig(
56
86
  },
57
87
  gateMarker: GATE_RE.source,
58
88
  verdictMarker: VERDICT_RE.source,
89
+ gateEvidence: "enforce",
90
+ evidencePattern: DEFAULT_EVIDENCE_PATTERN,
91
+ toolPolicy: {
92
+ mode: "enforce",
93
+ // Git-protective defaults, encoding src/policy.ts verbatim. Case-insensitive
94
+ // regex sources over bash/shell commands. Each git rule uses a global-option
95
+ // tolerant base (`\\bgit\\b[^;&|]*?\\s<subcommand>`), so a global option
96
+ // (`-C dir`, `--git-dir=…`, a `git alias`) between `git` and the subcommand
97
+ // still matches without terminating at a shell operator; the branch guard
98
+ // excludes the file-restore form so `git [opts] checkout [<ref>] -- <path>`
99
+ // is allowed while `git [opts] checkout <branch>` / `-b`/`-C` is blocked.
100
+ // Tradeoff (see README limits): the wider base can over-match on the bare
101
+ // subcommand word elsewhere, e.g. `git commit -m "… merge …"`.
102
+ bashDeny: [
103
+ "\\bgit\\b[^;&|]*?\\sworktree\\s+(add|remove|prune)\\b",
104
+ "\\bgit\\b[^;&|]*?\\s(switch|checkout)\\b(?![^;&|]*?--\\s)",
105
+ "\\bgit\\b[^;&|]*?\\smerge\\b",
106
+ "\\bgh\\s+pr\\s+(create|merge)\\b",
107
+ ],
108
+ // No default path blocks: write/edit are covered (a decision is made and,
109
+ // on block, journaled) without breaking ordinary editing.
110
+ pathDeny: [],
111
+ observeSessionMove: true,
112
+ },
113
+ // Git-and-commit gates ship in `shadow` (audit-only) by default: the gates
114
+ // are active but non-blocking until a user flips a key to `enforce`,
115
+ // mirroring the tool-policy "adopt shadow first" adoption path. `commitFormat`
116
+ // is a conventional-commits regex SOURCE (no anchors): callers wrap it with
117
+ // `^…$` + the `"s"` flag (see src/tool-policy.ts).
118
+ gitGates: {
119
+ mode: "shadow",
120
+ commitMessages: true,
121
+ commitFormat: DEFAULT_COMMIT_FORMAT,
122
+ landingGate: true,
123
+ },
59
124
  }
60
125
  }
61
126
 
@@ -67,10 +132,29 @@ export function defaultConfig(
67
132
  */
68
133
  function isValidScalar(key: keyof TurnstileConfig, value: unknown): boolean {
69
134
  if (key === "trace") return typeof value === "boolean"
70
- if (key === "metricsDir" || key === "waiverMarker" || key === "plansDir") {
135
+ if (
136
+ key === "metricsDir" ||
137
+ key === "waiverMarker" ||
138
+ key === "approvalMarker" ||
139
+ key === "plansDir"
140
+ ) {
141
+ if (key === "metricsDir") {
142
+ return typeof value === "string" && value.trim().length > 0 && path.isAbsolute(value)
143
+ }
71
144
  return typeof value === "string" && value.trim().length > 0
72
145
  }
73
146
  if (key === "gateMarker" || key === "verdictMarker") return typeof value === "string"
147
+ if (key === "gateEvidence" && (value === "enforce" || value === "flag" || value === "off"))
148
+ return true
149
+ if (key === "evidencePattern" && typeof value === "string") {
150
+ // A non-compiling pattern is rejected so `new RegExp` never throws later.
151
+ try {
152
+ new RegExp(value, "i")
153
+ return true
154
+ } catch {
155
+ return false
156
+ }
157
+ }
74
158
  return typeof value === "number" && Number.isFinite(value) && value > 0
75
159
  }
76
160
 
@@ -96,9 +180,12 @@ function assignScalar(config: TurnstileConfig, key: keyof TurnstileConfig, value
96
180
  if (key === "trace") config.trace = value as boolean
97
181
  else if (key === "metricsDir") config.metricsDir = value as string
98
182
  else if (key === "waiverMarker") config.waiverMarker = value as string
183
+ else if (key === "approvalMarker") config.approvalMarker = value as string
99
184
  else if (key === "plansDir") config.plansDir = value as string
100
185
  else if (key === "gateMarker") config.gateMarker = value as string
101
186
  else if (key === "verdictMarker") config.verdictMarker = value as string
187
+ else if (key === "gateEvidence") config.gateEvidence = value as GateEvidenceMode
188
+ else if (key === "evidencePattern") config.evidencePattern = value as string
102
189
  else config[key as NumericConfigKey] = value as number
103
190
  }
104
191
 
@@ -115,7 +202,12 @@ export function mergeConfig(
115
202
  patch: unknown,
116
203
  ): { config: TurnstileConfig; warnings: string[] } {
117
204
  const warnings: string[] = []
118
- const config: TurnstileConfig = { ...base, agents: { ...base.agents } }
205
+ const config: TurnstileConfig = {
206
+ ...base,
207
+ agents: { ...base.agents },
208
+ toolPolicy: { ...base.toolPolicy },
209
+ gitGates: { ...base.gitGates },
210
+ }
119
211
  if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
120
212
  if (patch !== undefined) {
121
213
  warnings.push(
@@ -160,10 +252,130 @@ export function mergeConfig(
160
252
  }
161
253
  continue
162
254
  }
255
+ if (key === "toolPolicy") {
256
+ // Merge one level deep like `agents`. A non-object keeps all defaults; each
257
+ // known sub-key is validated, and invalid bashDeny/pathDeny elements are
258
+ // dropped individually (compiled with "i") with one warning each so a
259
+ // config typo weakens the policy visibly in the journal, never silently.
260
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
261
+ warnings.push("config: toolPolicy must be an object; keeping defaults")
262
+ continue
263
+ }
264
+ const merged: TurnstileToolPolicy = { ...config.toolPolicy }
265
+ for (const [tpKey, tpValue] of Object.entries(value as Record<string, unknown>)) {
266
+ if (tpKey === "mode") {
267
+ if (tpValue === "enforce" || tpValue === "shadow" || tpValue === "off") {
268
+ merged.mode = tpValue as ToolPolicyMode
269
+ } else {
270
+ warnings.push(
271
+ 'config: toolPolicy.mode must be "enforce", "shadow", or "off"; keeping default',
272
+ )
273
+ }
274
+ } else if (tpKey === "observeSessionMove") {
275
+ if (typeof tpValue === "boolean") {
276
+ merged.observeSessionMove = tpValue
277
+ } else {
278
+ warnings.push(
279
+ "config: toolPolicy.observeSessionMove must be a boolean; keeping default",
280
+ )
281
+ }
282
+ } else if (tpKey === "bashDeny" || tpKey === "pathDeny") {
283
+ // Only the value itself being non-array falls back to the default
284
+ // (fail-safe); otherwise every element is validated so a single bad
285
+ // element never discards the whole user list, nor silently widens the
286
+ // policy by letting an empty/blank string through. Bad elements are
287
+ // dropped individually and journaled ({type:"error"} via loadConfig),
288
+ // mirroring the non-compiling-regex drop below.
289
+ if (!Array.isArray(tpValue)) {
290
+ warnings.push(
291
+ `config: toolPolicy.${tpKey} must be an array of non-empty strings; keeping default`,
292
+ )
293
+ continue
294
+ }
295
+ const sources: string[] = []
296
+ tpValue.forEach((src, i) => {
297
+ if (typeof src !== "string" || src.trim().length === 0) {
298
+ warnings.push(
299
+ `config: toolPolicy.${tpKey}[${i}] is not a non-empty string; dropping element`,
300
+ )
301
+ return
302
+ }
303
+ try {
304
+ new RegExp(src, "i")
305
+ sources.push(src)
306
+ } catch {
307
+ warnings.push(
308
+ `config: toolPolicy.${tpKey}[${i}] is not a valid regex; dropping element`,
309
+ )
310
+ }
311
+ })
312
+ merged[tpKey] = sources
313
+ } else {
314
+ warnings.push(`config: unknown toolPolicy key "${tpKey}" ignored`)
315
+ }
316
+ }
317
+ config.toolPolicy = merged
318
+ continue
319
+ }
320
+ if (key === "gitGates") {
321
+ // Merge one level deep like `toolPolicy` and `agents`. A non-object keeps
322
+ // all defaults; each known sub-key is validated individually with a
323
+ // journaled warning so a config typo weakens the gate visibly, never
324
+ // silently. `commitFormat` is a regex SOURCE (compilation checked with the
325
+ // same `^…$` + `"s"` wrap the caller uses, so `new RegExp` never throws).
326
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
327
+ warnings.push("config: gitGates must be an object; keeping defaults")
328
+ continue
329
+ }
330
+ const merged: TurnstileGitGates = { ...config.gitGates }
331
+ for (const [gk, gv] of Object.entries(value as Record<string, unknown>)) {
332
+ if (gk === "mode") {
333
+ if (gv === "enforce" || gv === "shadow" || gv === "off") {
334
+ merged.mode = gv as ToolPolicyMode
335
+ } else {
336
+ warnings.push(
337
+ 'config: gitGates.mode must be "enforce", "shadow", or "off"; keeping default',
338
+ )
339
+ }
340
+ } else if (gk === "commitMessages" || gk === "landingGate") {
341
+ if (typeof gv === "boolean") {
342
+ if (gk === "commitMessages") merged.commitMessages = gv
343
+ else merged.landingGate = gv
344
+ } else {
345
+ warnings.push(`config: gitGates.${gk} must be a boolean; keeping default`)
346
+ }
347
+ } else if (gk === "commitFormat") {
348
+ if (typeof gv !== "string") {
349
+ warnings.push("config: gitGates.commitFormat must be a string; keeping default")
350
+ } else {
351
+ try {
352
+ new RegExp("^" + gv + "$", "s")
353
+ merged.commitFormat = gv
354
+ } catch {
355
+ warnings.push("config: gitGates.commitFormat is not a valid regex; keeping default")
356
+ }
357
+ }
358
+ } else {
359
+ warnings.push(`config: unknown gitGates key "${gk}" ignored`)
360
+ }
361
+ }
362
+ config.gitGates = merged
363
+ continue
364
+ }
163
365
  if (!(key in config)) {
164
366
  warnings.push(`config: unknown key "${key}" ignored`)
165
367
  continue
166
368
  }
369
+ if (key === "gateEvidence") {
370
+ // Validated/assigned by the shared helpers; only the warning wording is
371
+ // special-cased to name the three accepted literals.
372
+ if (isValidScalar(key as keyof TurnstileConfig, value)) {
373
+ assignScalar(config, key as keyof TurnstileConfig, value)
374
+ } else {
375
+ warnings.push(`config: gateEvidence must be "enforce", "flag", or "off"; keeping default`)
376
+ }
377
+ continue
378
+ }
167
379
  if (isValidScalar(key as keyof TurnstileConfig, value)) {
168
380
  assignScalar(config, key as keyof TurnstileConfig, value)
169
381
  } else {
@@ -234,12 +446,66 @@ function applyEnvLayer(config: TurnstileConfig, env: Record<string, string | und
234
446
  }
235
447
  const metricsDir = env[ENV_KEYS.metricsDir]
236
448
  if (metricsDir !== undefined) {
237
- if (metricsDir.trim().length > 0) config.metricsDir = metricsDir
238
- else warnings.push(`env ${ENV_KEYS.metricsDir} empty; keeping current value`)
449
+ if (metricsDir.trim().length === 0) {
450
+ warnings.push(`env ${ENV_KEYS.metricsDir} empty; keeping current value`)
451
+ } else if (!path.isAbsolute(metricsDir)) {
452
+ warnings.push(`env ${ENV_KEYS.metricsDir}="${metricsDir}" is relative; keeping current value`)
453
+ } else {
454
+ config.metricsDir = metricsDir
455
+ }
239
456
  }
240
457
  return warnings
241
458
  }
242
459
 
460
+ /**
461
+ * Defends the fully-resolved config against inter-key and path invariants that
462
+ * no single layer validator catches, so hand-constructed configs cannot slip a
463
+ * bad value past the merge-time checks. Mutates `config` in place (clamping
464
+ * `readWarn` below `readBlock`, warning on a relative `metricsDir`) and
465
+ * returns one warning string per correction.
466
+ *
467
+ * Called once at the end of `loadConfig` after every layer has merged, so the
468
+ * defaults (8 < 15, absolute dir) never trip it and the golden digest stays
469
+ * stable. Exported for tests: every merge layer forces an absolute
470
+ * `metricsDir`, so the relative-dir warning is reachable only directly.
471
+ * @param config The resolved config to validate.
472
+ * @returns One warning string per corrected/resolved invariant.
473
+ */
474
+ export function validateResolved(config: TurnstileConfig): string[] {
475
+ const warnings: string[] = []
476
+ if (config.readWarn >= config.readBlock) {
477
+ const warn = config.readWarn
478
+ const block = config.readBlock
479
+ config.readWarn = Math.max(block - 1, 0)
480
+ warnings.push(
481
+ `config: readWarn (${warn}) >= readBlock (${block}); clamping readWarn to ${config.readWarn}`,
482
+ )
483
+ }
484
+ if (!path.isAbsolute(config.metricsDir)) {
485
+ warnings.push(
486
+ `config: metricsDir "${config.metricsDir}" is relative; journaling resolves against the process cwd`,
487
+ )
488
+ }
489
+ return warnings
490
+ }
491
+
492
+ /**
493
+ * Re-derives `bareOutput` from the configured agent names when the resolved
494
+ * list is still the unchanged default literal (i.e. nobody configured it), so
495
+ * a renamed agent stays bare-output-qualified automatically. Explicit lists
496
+ * are kept verbatim so no configured name ever vanishes. Mutates
497
+ * `agents.bareOutput` in place.
498
+ * @param agents The resolved agents block to update in place.
499
+ * @param defaultBareOutput The current default bare-output literal.
500
+ */
501
+ function deriveBareOutput(agents: TurnstileAgents, defaultBareOutput: string[]): void {
502
+ if (JSON.stringify(agents.bareOutput) !== JSON.stringify(defaultBareOutput)) return
503
+ const derived = [agents.testRunner, agents.reviewer, agents.planReviewer].filter(
504
+ (name): name is string => typeof name === "string" && name.trim().length > 0,
505
+ )
506
+ agents.bareOutput = derived.filter((name, i) => derived.indexOf(name) === i)
507
+ }
508
+
243
509
  /**
244
510
  * Resolves the full layered configuration: defaults ⊕ config file ⊕ env ⊕
245
511
  * injected overrides. Config-file and validation problems are journaled to
@@ -254,8 +520,9 @@ export function loadConfig(
254
520
  overrides?: Partial<TurnstileConfig>,
255
521
  ): { config: TurnstileConfig; warnings: string[] } {
256
522
  const warnings: string[] = []
523
+ const defaults = defaultConfig(env)
257
524
  const fileLayer = readFileLayer(env)
258
- const fileMerge = mergeConfig(defaultConfig(env), fileLayer.patch)
525
+ const fileMerge = mergeConfig(defaults, fileLayer.patch)
259
526
  const config = fileMerge.config
260
527
  warnings.push(...fileLayer.warnings, ...fileMerge.warnings)
261
528
  warnings.push(...applyEnvLayer(config, env))
@@ -265,8 +532,30 @@ export function loadConfig(
265
532
  resolved = merged.config
266
533
  warnings.push(...merged.warnings)
267
534
  }
535
+ warnings.push(...validateResolved(resolved))
536
+ deriveBareOutput(resolved.agents, defaults.agents.bareOutput)
268
537
  for (const warning of warnings) {
269
538
  if (!warning.startsWith("config file")) journalConfigError(warning, env)
270
539
  }
271
540
  return { config: resolved, warnings }
272
541
  }
542
+
543
+ /**
544
+ * Computes a short, stable digest of a resolved config.
545
+ *
546
+ * The digest is the first 12 hex characters of the SHA-256 of the config's
547
+ * JSON serialization: two setups with the same config produce the same
548
+ * digest, and any config change flips it — so the digest ties a session's
549
+ * journal entries to the exact config that drove them (and is reproducible
550
+ * offline). `JSON.stringify` serializes a plain object's keys in insertion
551
+ * order, and every config is built by the same literal + spread paths
552
+ * (`defaultConfig`, `mergeConfig`), so the serialization is stable *today*.
553
+ * A future refactor that conditionally inserts keys would change the
554
+ * serialization and invalidate historical digests — by design, a changed
555
+ * config should no longer match an old one.
556
+ * @param config The resolved config to digest.
557
+ * @returns 12 lowercase hex characters.
558
+ */
559
+ export function configDigest(config: TurnstileConfig): string {
560
+ return createHash("sha256").update(JSON.stringify(config)).digest("hex").slice(0, 12)
561
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @fileoverview Evidence boundary: correlates an observed `GATE: PASS` against
3
+ * journaled successful (exit 0) bash/shell test-command executions attributed
4
+ * to the emitting session's parent within the current task window.
5
+ *
6
+ * This owns only the *evidence* dimension — whether the claimed tests ran.
7
+ * Marker authority (*who* may flip gates) is deliberately separate
8
+ * and out of scope here. The ledger is in-memory only (never command text);
9
+ * audited verdicts are journaled so replay restores evidence-bearing state
10
+ * rather than re-proving it. Pure w.r.t. journal/fs by design.
11
+ */
12
+
13
+ import { DEFAULT_EVIDENCE_PATTERN } from "./config.ts"
14
+ import type { Journaler } from "./journal.ts"
15
+ import type { TurnstileConfig } from "./types.ts"
16
+
17
+ /** Per-session hit cap: a ring buffer of exit-0 hit timestamps. */
18
+ const MAX_HITS_PER_SESSION = 8
19
+
20
+ /** Fallback pattern used when the configured `evidencePattern` fails to compile. */
21
+ const FALLBACK_EVIDENCE_PATTERN = new RegExp(DEFAULT_EVIDENCE_PATTERN, "i")
22
+
23
+ /**
24
+ * Compiles the evidence-pattern regex once, with a `compileMarkerRegex`-style
25
+ * fallback: on a compile failure the caller is notified via `onFallback` and
26
+ * the default pattern is used.
27
+ * @param source The configured pattern source.
28
+ * @param fallback Pattern to return on compile failure.
29
+ * @param onFallback Optional callback invoked with the caught cause first.
30
+ * @returns The compiled pattern (configured, or the fallback).
31
+ */
32
+ function compileEvidencePattern(
33
+ source: string,
34
+ fallback: RegExp,
35
+ onFallback?: (cause: unknown) => void,
36
+ ): RegExp {
37
+ try {
38
+ return new RegExp(source, "i")
39
+ } catch (cause) {
40
+ onFallback?.(cause)
41
+ return fallback
42
+ }
43
+ }
44
+
45
+ /** Evidence tracker: recorded exit-0 bash/shell hits and their resolution. */
46
+ export interface EvidenceTracker {
47
+ /**
48
+ * Records a plausible exit-0 test execution under `raw`, returning whether a
49
+ * hit was recorded. Non-zero exits and non-matching commands are ignored;
50
+ * the command text is never stored.
51
+ * @param raw The session ID that ran the command.
52
+ * @param command The command string (matched, never retained).
53
+ * @param exit The command exit code.
54
+ * @returns True when a hit was recorded.
55
+ */
56
+ observe(raw: string, command: unknown, exit: unknown): boolean
57
+ /**
58
+ * True when `sid` or any child whose child→parent edge resolves to `sid`
59
+ * has a recorded hit. Re-keys a found child bucket to `sid` (lazy
60
+ * promotion, so an async parent edge established after `observe` still
61
+ * resolves).
62
+ * @param sid The parent (or raw) session ID to check.
63
+ * @returns True when correlated evidence exists.
64
+ */
65
+ has(sid: string): boolean
66
+ /**
67
+ * Drops `sid`'s bucket and every child's bucket whose edge resolves to `sid`.
68
+ * @param sid The parent (or raw) session ID to clear.
69
+ */
70
+ clear(sid: string): void
71
+ }
72
+
73
+ /**
74
+ * Creates an in-memory evidence tracker bound to a config + parent resolver.
75
+ * A configured `evidencePattern` that fails to compile falls back to the
76
+ * default pattern and journals the fallback via `journaler`.
77
+ * @param config The resolved config (provides `evidencePattern`, `maxSessions`).
78
+ * @param parentOf Resolves a raw session ID to its parent (the ledger's `parentOf`).
79
+ * @param journaler Optional telemetry writer; used only to journal the
80
+ * pattern-fallback (a compile failure) when set.
81
+ * @returns The observe/has/clear tracker.
82
+ */
83
+ export function createEvidenceTracker(
84
+ config: TurnstileConfig,
85
+ parentOf: (raw: string) => string,
86
+ journaler?: Pick<Journaler, "journal">,
87
+ ): EvidenceTracker {
88
+ const pattern = compileEvidencePattern(
89
+ config.evidencePattern,
90
+ FALLBACK_EVIDENCE_PATTERN,
91
+ (cause) =>
92
+ journaler?.journal({
93
+ type: "error",
94
+ error: `config evidencePattern: invalid regex, using default: ${String(cause)}`,
95
+ }),
96
+ )
97
+ // Raw session ID → ring of exit-0 hit timestamps; insertion-order LRU,
98
+ // capped at `config.maxSessions` (mirroring state.ts bookkeeping).
99
+ const hits = new Map<string, number[]>()
100
+
101
+ /**
102
+ * Promotes a key to most-recently-used in an insertion-ordered map.
103
+ * @param map The map to reorder.
104
+ * @param key The key to touch (no-op when absent).
105
+ */
106
+ function promote(map: Map<string, number[]>, key: string): void {
107
+ const v = map.get(key)
108
+ if (v !== undefined) {
109
+ map.delete(key)
110
+ map.set(key, v)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Evicts least-recently-used entries until within the cap.
116
+ * @param map The map to trim.
117
+ */
118
+ function cap(map: Map<string, number[]>): void {
119
+ while (map.size > config.maxSessions) {
120
+ // The loop condition guarantees a first entry exists.
121
+ const oldest = map.keys().next().value as string
122
+ map.delete(oldest)
123
+ }
124
+ }
125
+
126
+ function observe(raw: string, command: unknown, exit: unknown): boolean {
127
+ if (!raw) return false
128
+ if (exit !== 0) return false
129
+ if (!pattern.test(String(command ?? ""))) return false
130
+ const bucket = [...(hits.get(raw) ?? []), Date.now()]
131
+ if (bucket.length > MAX_HITS_PER_SESSION) bucket.length = MAX_HITS_PER_SESSION
132
+ hits.set(raw, bucket)
133
+ cap(hits)
134
+ return true
135
+ }
136
+
137
+ function has(sid: string): boolean {
138
+ if (!sid) return false
139
+ // Touch unconditionally: present keys reorder to MRU, absent keys no-op.
140
+ promote(hits, sid)
141
+ if (hits.has(sid)) return true
142
+ for (const [child, bucket] of hits) {
143
+ if (parentOf(child) === sid) {
144
+ hits.delete(child)
145
+ hits.set(sid, bucket)
146
+ promote(hits, sid)
147
+ return true
148
+ }
149
+ }
150
+ return false
151
+ }
152
+
153
+ function clear(sid: string): void {
154
+ if (!sid) return
155
+ hits.delete(sid)
156
+ for (const [child] of hits) {
157
+ if (parentOf(child) === sid) hits.delete(child)
158
+ }
159
+ }
160
+
161
+ return { observe, has, clear }
162
+ }