@gcunharodrigues/wrxn 0.18.3 → 0.20.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.
package/manifest.json CHANGED
@@ -208,6 +208,11 @@
208
208
  "class": "managed",
209
209
  "profile": "project"
210
210
  },
211
+ {
212
+ "path": ".claude/skills/chat-search/SKILL.md",
213
+ "class": "managed",
214
+ "profile": "project"
215
+ },
211
216
  {
212
217
  "path": ".claude/skills/memory/SKILL.md",
213
218
  "class": "managed",
@@ -548,6 +553,11 @@
548
553
  "class": "state",
549
554
  "profile": "project"
550
555
  },
556
+ {
557
+ "path": ".wrxn/chat-search.cjs",
558
+ "class": "managed",
559
+ "profile": "project"
560
+ },
551
561
  {
552
562
  "path": ".wrxn/wiki.cjs",
553
563
  "class": "managed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.18.3",
3
+ "version": "0.20.0",
4
4
  "description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
5
5
  "bin": {
6
6
  "wrxn": "bin/wrxn.cjs"
@@ -8,6 +8,12 @@
8
8
  // and skipped anyway — so this is the hard speedbump that points the orchestrator back to the
9
9
  // right main-thread skill.
10
10
  //
11
+ // SLICE #90 adds a second arm: a PreToolUse:Bash guard that catches a MAIN-THREAD skip — a
12
+ // pipeline-bypassing shell command (gh issue/pr create, gh pr merge, trunk git push) run directly
13
+ // in the main thread — and warns (default) or blocks (env knob) with the same redirect-to-skill
14
+ // intent. Caller context (the `agent_id` stdin field) separates a skip from the identical command
15
+ // run legitimately inside a typed-executor subagent. (ADR 0009.)
16
+ //
11
17
  // AC-1 — HOOK EVENT DETERMINATION: PreToolUse:Task.
12
18
  // Claude Code matches PreToolUse matchers against the TOOL NAME, and `Task` (the subagent-spawn
13
19
  // tool) is a matchable tool name, so a `PreToolUse` entry with matcher `Task` fires BEFORE a
@@ -19,10 +25,13 @@
19
25
  // Self-contained: ships into installs, MUST NOT import the kernel lib (node stdlib only).
20
26
  // Fail-open: any parse error / missing field emits {} — the hook NEVER wedges a session.
21
27
  //
22
- // Contract: PreToolUse event JSON on stdin -> decision JSON on stdout (exit 0).
23
- // allow -> {} block -> { "decision": "block", "reason": "..." }
28
+ // Contract: PreToolUse event JSON on stdin -> decision JSON on stdout (exit 0). main() dispatches on
29
+ // tool_name:
30
+ // Task (delegation skip): allow -> {} block -> { "decision": "block", "reason": "..." } (legacy)
31
+ // Bash (main-thread skip): allow -> {} warn/block -> { "hookSpecificOutput": { ... } } (modern)
24
32
 
25
33
  const fs = require('fs');
34
+ const { execFileSync } = require('child_process');
26
35
 
27
36
  // The six typed executors (mirrors lib/executor.cjs EXECUTORS keys — hardcoded here because the hook
28
37
  // is self-contained and cannot import the kernel lib). These ARE the pipeline; they are always allowed
@@ -80,6 +89,134 @@ function decide({ subagent_type, prompt } = {}) {
80
89
  return { block: true, reason: reasonFor(skills) };
81
90
  }
82
91
 
92
+ // ── Bash arm (slice #90) — main-thread pipeline-skip detection ─────────────────────────
93
+ // The Task arm above catches a DELEGATION skip (a HITL step handed to a non-typed agent).
94
+ // This arm catches a MAIN-THREAD skip: the operator/assistant running a pipeline-bypassing
95
+ // command directly (no Task spawn to intercept). The discriminator is CALLER CONTEXT, not the
96
+ // command text: every legitimate pipeline mechanic (devops -> `wrxn ship`; all AFK executors)
97
+ // runs inside a typed-executor SUBAGENT and so carries `agent_id` on the PreToolUse stdin —
98
+ // auto-allowed. Only main-thread ops (`agent_id` absent) are candidates. (ADR 0009.)
99
+
100
+ const SKILL_SPEC = 'to-prd / to-issues'; // the issue-filing pipeline skills
101
+ const SKILL_SHIP = 'wrxn ship / the devops executor'; // the promotion pipeline path
102
+
103
+ function bashMessage(verb, skill) {
104
+ return (
105
+ `Pipeline guard: running \`${verb}\` directly in the main thread bypasses the build pipeline. ` +
106
+ `Use ${skill} instead — the pipeline (and the server-side CI ruleset) is the gate, ` +
107
+ `not a manual main-thread command.`
108
+ );
109
+ }
110
+
111
+ function isTrunk(name) {
112
+ return name === 'main' || name === 'master';
113
+ }
114
+
115
+ // Normalize a `git push` target to a bare branch name before the trunk compare. Handles the refspec
116
+ // forms that would otherwise evade the catch: a colon refspec (local:remote) targets the REMOTE side
117
+ // (after the last ':'); a leading `+` is the force marker; `refs/heads/` is the fully-qualified prefix.
118
+ // So `+main`, `refs/heads/main`, `+refs/heads/main`, `HEAD:main` all normalize to `main`. (security S2.)
119
+ function normalizeRef(token) {
120
+ let ref = token.includes(':') ? token.split(':').pop() : token;
121
+ ref = ref.replace(/^\+/, ''); // strip a leading force marker
122
+ ref = ref.replace(/^refs\/heads\//, ''); // strip a fully-qualified branch prefix
123
+ return ref;
124
+ }
125
+
126
+ // Guarded, READ-ONLY shell-out: resolve the current branch for an argless / remote-only `git push`.
127
+ // Any failure -> null, and the caller fails open to allow. This is the only side-effecting path; it
128
+ // is injected into `decideBash` (default below) so every unit test drives it without spawning git.
129
+ function currentBranch() {
130
+ try {
131
+ const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
132
+ encoding: 'utf8',
133
+ stdio: ['ignore', 'pipe', 'ignore'],
134
+ });
135
+ return out.trim() || null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // Does this `git push` land on the trunk? Only a trunk push is a skip; feature-branch work is normal.
142
+ function pushesToTrunk(cmd, resolveBranch) {
143
+ // Bounded ranges (not unbounded `[\s\S]*?`) keep this linear on the hot path. (security S1.)
144
+ const m = cmd.match(/\bgit\b[\s\S]{0,80}\bpush\b([\s\S]*)/);
145
+ const rest = m ? m[1] : '';
146
+ const positionals = rest.split(/\s+/).filter((t) => t && !t.startsWith('-'));
147
+ const refs = positionals.map(normalizeRef); // normalize force / fully-qualified / colon refspecs
148
+ if (refs.some(isTrunk)) return true; // an explicit main/master ref (any refspec form)
149
+ if (positionals.length >= 2) return false; // an explicit non-trunk branch was named
150
+ // no branch named (argless or remote-only) -> resolve the current branch
151
+ let branch = null;
152
+ try {
153
+ branch = resolveBranch();
154
+ } catch {
155
+ branch = null;
156
+ }
157
+ return branch ? isTrunk(branch) : false; // unresolvable -> fail open (allow)
158
+ }
159
+
160
+ // PURE: { command, agentId, level } -> { action: 'allow'|'warn'|'block', skill?, message? }.
161
+ // Unit-tested apart from stdin (mirrors the Task arm's `decide()` seam). `resolveBranch` is an
162
+ // injected boundary (defaults to a guarded `git rev-parse`) so the argless-push path stays testable.
163
+ function decideBash({ command, agentId, level, resolveBranch = currentBranch } = {}) {
164
+ const cmd = typeof command === 'string' ? command : '';
165
+ if (!cmd.trim()) return { action: 'allow' }; // missing command -> fail open
166
+
167
+ if (agentId) return { action: 'allow' }; // spine: present = subagent = the pipeline -> allow
168
+
169
+ // Posture knob (default/unknown -> warn). `off` disables the Bash arm entirely (kill switch);
170
+ // the Task arm is never gated by the knob.
171
+ const raw = typeof level === 'string' ? level.trim().toLowerCase() : '';
172
+ const lvl = raw === 'block' || raw === 'off' ? raw : 'warn';
173
+ if (lvl === 'off') return { action: 'allow' };
174
+
175
+ // A catch command's action: `block` only under the block posture, else `warn`.
176
+ const catchAction = (verb, skill) => ({
177
+ action: lvl === 'block' ? 'block' : 'warn',
178
+ skill,
179
+ message: bashMessage(verb, skill),
180
+ });
181
+
182
+ // `gh issue create` is LOCKED to warn under every level (incl. block): to-prd/to-issues/triage
183
+ // run it themselves in the main thread, so a block would wedge the redirect skills. (ADR 0009 §4.)
184
+ if (/\bgh\b[\s\S]{0,80}\bissue\b[\s\S]{0,80}\bcreate\b/.test(cmd)) {
185
+ return { action: 'warn', skill: SKILL_SPEC, message: bashMessage('gh issue create', SKILL_SPEC) };
186
+ }
187
+
188
+ if (/\bgh\b[\s\S]{0,80}\bpr\b[\s\S]{0,80}\bcreate\b/.test(cmd)) {
189
+ return catchAction('gh pr create', SKILL_SHIP);
190
+ }
191
+
192
+ // A PR merges into its base (the trunk); promotion is a `wrxn ship` PR gated by CI, not a manual merge.
193
+ if (/\bgh\b[\s\S]{0,80}\bpr\b[\s\S]{0,80}\bmerge\b/.test(cmd)) {
194
+ return catchAction('gh pr merge', SKILL_SHIP);
195
+ }
196
+
197
+ // Only a TRUNK push is a skip — promotion is a `wrxn ship` PR, not a direct push to main/master.
198
+ if (/\bgit\b[\s\S]{0,80}\bpush\b/.test(cmd) && pushesToTrunk(cmd, resolveBranch)) {
199
+ return catchAction('git push', SKILL_SHIP);
200
+ }
201
+
202
+ return { action: 'allow' };
203
+ }
204
+
205
+ // Modern PreToolUse output (AC#0-confirmed): a warn lets the tool run while nudging both the
206
+ // assistant (additionalContext) and the operator (systemMessage); a block denies with a reason.
207
+ function warnOutput(message) {
208
+ return {
209
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', additionalContext: message },
210
+ systemMessage: message,
211
+ };
212
+ }
213
+
214
+ function blockOutput(message) {
215
+ return {
216
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
217
+ };
218
+ }
219
+
83
220
  function emit(decision) {
84
221
  process.stdout.write(JSON.stringify(decision));
85
222
  process.exit(0);
@@ -98,6 +235,20 @@ function main() {
98
235
  // reach event.tool_name and throw uncaught past the try. Guard it -> fail open. (gate-07 INFO)
99
236
  if (!event || typeof event !== 'object') return emit({});
100
237
 
238
+ // Bash arm (slice #90): catch a MAIN-THREAD pipeline skip. The discriminator is the snake_case
239
+ // `agent_id` stdin field (present only inside a subagent); the posture comes from process.env.
240
+ if (event.tool_name === 'Bash') {
241
+ const ti = event.tool_input || {};
242
+ const d = decideBash({
243
+ command: ti.command,
244
+ agentId: event.agent_id, // presence = subagent = the pipeline -> allow
245
+ level: process.env.WRXN_PIPELINE_GUARD,
246
+ });
247
+ if (d.action === 'warn') return emit(warnOutput(d.message));
248
+ if (d.action === 'block') return emit(blockOutput(d.message));
249
+ return emit({}); // allow
250
+ }
251
+
101
252
  // Only gate the Task (subagent-spawn) tool; anything else -> allow.
102
253
  if (event.tool_name && event.tool_name !== 'Task') return emit({});
103
254
 
@@ -113,4 +264,4 @@ if (require.main === module) {
113
264
  main();
114
265
  }
115
266
 
116
- module.exports = { decide };
267
+ module.exports = { decide, decideBash };
@@ -35,7 +35,8 @@
35
35
  {
36
36
  "matcher": "Bash",
37
37
  "hooks": [
38
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-managed-precommit.cjs\"" }
38
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-managed-precommit.cjs\"" },
39
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-pipeline-adherence.cjs\"" }
39
40
  ]
40
41
  },
41
42
  {
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: chat-search
3
+ description: On-demand retrieval over the Conversational log — grep an exact past moment (timestamp · session · role · snippet) across both arms: the event log AND the harness transcript (user and assistant turns). Operator- and agent-invocable; never an automatic hook.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # chat-search — on-demand retrieval over the Conversational log
8
+
9
+ `chat-search` retrieves an **exact past conversational moment** on demand — "what did I say about X", "you decided Y earlier". It greps the **Conversational log** (raw, machine-local scrollback), not the Brain: no embeddings, no recon/serve dependency, no index step (ADR 0008). It is **deliberate-only** — the operator types it or the agent reaches for it mid-reasoning (like `recon_find`). It is **never** wired as a `UserPromptSubmit` (or any automatic) hook — Recall's prose-only auto-surface stays the only per-prompt surface (ADR 0002 boundary).
10
+
11
+ > Scrollback, not memory. The Conversational log is ephemeral and pruned (~90d). Durable, portable memory remains the committed wiki — use the `memory` skill for that.
12
+
13
+ ## Scope — both arms of the Conversational log
14
+
15
+ `chat-search` reads **both arms** and merges them into one recency-first result:
16
+
17
+ - **Event log** — `.wrxn/events/*.jsonl`, the per-session, secret-redacted **user prompts** emit-event.cjs appends.
18
+ - **Harness transcript** — `~/.claude/projects/<slug>/*.jsonl`, the only source of **assistant** turns and full user/assistant **message content**. The `<slug>` is the project's absolute path with every non-alphanumeric character replaced by `-` (how the harness names the dir). The transcript arm is **hygiene-cleaned** before matching (see below).
19
+
20
+ A prompt that appears in **both** arms is de-duplicated — by `(session, timestamp, text)` — so it surfaces once. If the transcript dir is **missing or unreadable**, the search **degrades loudly to events-only** and says so in the output (it never crashes).
21
+
22
+ Three optional flags refine a search — `--session` (scope to one session), `--since` (a time floor), and `--regex` (pattern match instead of substring). They compose with each other and with both arms. See **Scoping & match flags** below.
23
+
24
+ ### Transcript hygiene (so a hit is real conversation, not framework noise)
25
+
26
+ - **Injected context is stripped** before matching: `<wrxn-orientation>`, `<synapse-rules>`, `<recall-surface>`, `<reference-candidate>`, and `<system-reminder>` blocks the hooks inject each turn are removed, so a term that lives **only inside** an injected block is **not** a hit.
27
+ - **Secrets are redacted** (the same canonical secret-shape set the memory adapters use): a credential echoed into chat never appears in a snippet.
28
+ - Only `text` content is searched — `thinking` / `tool_use` / `tool_result` blocks and unknown line `type`s (`summary`, `system`, …) are dropped.
29
+
30
+ ## Invocation
31
+
32
+ ### Operator
33
+
34
+ ```
35
+ /chat-search baton echo
36
+ ```
37
+
38
+ ### Engine (what the skill runs)
39
+
40
+ ```bash
41
+ node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]
42
+ ```
43
+
44
+ The engine resolves the install root by walking up to the `wrxn.install.json` receipt, so it runs from anywhere inside an install. Pass `--root <dir>` to override (mainly for tests). Flags follow the search term(s). It prints the rendered result and exits 0 — a nothing-found result is a normal outcome, never an error exit. **Invalid flag input** (a malformed/catastrophic `--regex`, an unparseable `--since`, an unsafe `--session`) fails **loud**: one clear line on stderr and a non-zero exit — never a stack trace or a hang.
45
+
46
+ ### Agent (self-invocation)
47
+
48
+ When the operator references an earlier moment ("like we discussed", "the decision from this morning") and you need the **exact** wording rather than a guess, run the engine and ground on what was actually said. Reach for it the same way you reach for `recon_find` — deliberately, when a past moment is needed.
49
+
50
+ ## Scoping & match flags
51
+
52
+ All three are optional, compose with each other, and apply across **both arms** (so scoping/filtering also covers assistant turns), preserving recency order and cross-arm dedup.
53
+
54
+ - **`--session <id>`** — scope results to a single session (exact match on the session id). The id is the harness/event session id (letters, digits, `-`, `_`); a malformed id is rejected. Scoped rows render as `this session`.
55
+ - **`--since <when>`** — keep only hits at or after a timestamp floor. `<when>` is either `today` (from 00:00 **UTC** of the current day — record stamps are UTC) or an ISO-8601 date/datetime (e.g. `2026-06-26` or `2026-06-26T12:00:00Z`). An undatable hit is excluded.
56
+ - **`--regex`** — match the search term as a **regular expression** instead of a case-insensitive substring. Regex mode is **case-sensitive** (the universal regex default; the substring default stays case-insensitive).
57
+
58
+ ```bash
59
+ node .wrxn/chat-search.cjs "gate.*decision" --regex --since today
60
+ node .wrxn/chat-search.cjs baton echo --session 9dc65f19-65fb-43cb-81fa-0340353f1cc5
61
+ ```
62
+
63
+ > **`--regex` safety (ReDoS).** The pattern is user-supplied and runs over whole transcripts, so it is bounded at compile (before any input is matched): it is **length-capped** (≤ 200 chars) and runs through a **nesting-aware static screen** that rejects a quantified group whose body repeats or alternates through **any** nesting depth — both flat shapes (`(a+)+`, `(a|a)+`, `(?:a*)*`) and nested ones a flat check misses (`((a)+)+`, `((\w)+)+$`, `((a+))+`, `(a(b+)c)+`) — plus **backreferences** (`\1`…`\9`). A rejected, malformed, or unparseable value fails loud. The screen is deliberately conservative (it also refuses the rare-but-safe `(foo|bar)+` — drop the outer quantifier), but does not over-reject ordinary grouping: `(foo|bar)`, `(ab)+`, `([a+])+`, `(?:ab)+`, `a{1,5}`, `\d{4}-\d{2}-\d{2}` are all fine. **Residual:** a static screen models structure, not match semantics, so under the no-timeout / `fs`-`os`-`path`-only constraint it refuses the known catastrophic shapes rather than proving a pattern safe; an exotic construct outside those shapes is not modelled.
64
+
65
+ ## Output
66
+
67
+ Hits are **most-recent-first**, one per line:
68
+
69
+ ```
70
+ <timestamp> · <session (or "this session")> · <role> · <snippet (±1 line context)>
71
+ ```
72
+
73
+ - `role` is `user` (event log or a user transcript turn) or `assistant` (a transcript turn).
74
+ - The session column collapses to `this session` for hits from the active session.
75
+ - No match → an explicit `chat-search: nothing found for "<term>" ...` line (never silence, never a crash).
76
+ - If the transcript arm is unavailable, a trailing `chat-search: transcript arm unavailable — showing event-log results only.` line is appended (loud degrade).
77
+
78
+ ## Boundaries
79
+
80
+ - **Pure in-process grep.** No Brain, no embeddings, no recon/serve/daemon, no network (ADR 0008). Node stdlib only (`fs` + `os` + `path`).
81
+ - **Read-only.** It never writes a wiki page, never mutates the event log or the transcript.
82
+ - **Never an automatic hook.** Invoked only by the operator or the agent, deliberately (ADR 0002).
83
+ - Default scope is **this project's sessions** — every `.jsonl` under `.wrxn/events/` plus this project's harness transcript under `~/.claude/projects/<slug>/`. The slug is **path-bounded** (it can never escape `~/.claude/projects`). Cross-project search is out of scope.
84
+
85
+ ## Source
86
+
87
+ WRXN Kernel issues #83 (slice 1 — event-log arm) + #84 (slice 2 — harness-transcript arm + hygiene) + #85 (slice 3 — scoping + match flags), under PRD #82. Engine: `.wrxn/chat-search.cjs`. Grounded by `docs/adr/0008-chat-search-ondemand-grep-outside-brain.md` and the `CONTEXT.md` terms **Conversational log** / **chat-search**.
@@ -84,7 +84,7 @@ build-time guard.
84
84
 
85
85
  ```buckets
86
86
  dev-pipeline: grill-me, grill-with-docs, tech-search, prototype, to-prd, to-issues, triage, tdd, qa-walk, diagnose, resolving-merge-conflicts, improve-codebase-architecture
87
- knowledge: dream, harvest, sync, ingest, memory
87
+ knowledge: dream, harvest, sync, ingest, memory, chat-search
88
88
  setup-health: onboard, audit, level-up, setup-matt-pocock-skills, synapse
89
89
  meta: write-a-skill, write-an-agent, skill-creator, compass
90
90
  cross-session: handoff
@@ -93,7 +93,7 @@ cross-session: handoff
93
93
  - **dev-pipeline** — the four-phase build flow and its engineering activities (grill → research →
94
94
  prototype → PRD → issues → verticality → tdd → review → security → qa-walk → diagnose / refactor).
95
95
  - **knowledge** — the Brain / wiki memory lifecycle: consolidate (dream), curate (harvest), drift (sync),
96
- distill sources (ingest), the wiki adapter (memory).
96
+ distill sources (ingest), the wiki adapter (memory), on-demand conversational-log retrieval (chat-search).
97
97
  - **setup-health** — install setup, configuration, and health: onboard, audit, level-up, the
98
98
  issue-tracker/domain block (setup-matt-pocock-skills), the SYNAPSE engine (synapse).
99
99
  - **meta** — authoring the OS's own extensions: skills (write-a-skill), agents (write-an-agent), the
@@ -0,0 +1,522 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN chat-search engine — on-demand retrieval over the Conversational log (kernel #83 slice 1, #84 slice 2).
5
+ // Self-contained: this ships INTO an install and MUST NOT import the kernel lib (node stdlib only).
6
+ //
7
+ // It scans BOTH arms of the Conversational log:
8
+ // · the EVENT-LOG arm — <root>/.wrxn/events/*.jsonl, the per-session, pre-redacted user-prompt records
9
+ // emit-event.cjs appends (slice 1);
10
+ // · the HARNESS-TRANSCRIPT arm — ~/.claude/projects/<slug>/*.jsonl, the only source of ASSISTANT turns
11
+ // and full user/assistant message content (slice 2 / #84). Its output is injected-context-stripped +
12
+ // secret-redacted, and a prompt present in both arms is de-duplicated.
13
+ // Pure in-process fs scan: no Brain, no embeddings, no recon/serve dependency, no network (ADR 0008). It
14
+ // degrades LOUDLY to events-only if the transcript dir is absent/unreadable. Never wired as a per-prompt
15
+ // hook — it is invoked deliberately, by the operator or the agent (ADR 0002 boundary).
16
+
17
+ const fs = require('fs');
18
+ const os = require('os'); // home-dir resolution only (the transcript arm lives under ~/.claude/projects) — NOT network/daemon/embedding.
19
+ const path = require('path');
20
+
21
+ // Only prompt records carry text in the event arm, so only they can match. role is derived from kind:
22
+ // a user prompt is kind 'prompt' → role 'user'.
23
+ function roleFromKind(kind) {
24
+ return kind === 'prompt' ? 'user' : String(kind || '');
25
+ }
26
+
27
+ // A snippet is the matching line plus ±1 line of context within the message text. `matchesLine` is the
28
+ // active matcher (case-insensitive substring by default, or the compiled --regex), so the snippet finds the
29
+ // SAME line the scan matched on. A single-line prompt collapses to just that line. A match that only spans a
30
+ // line boundary has no single matching line → fall back to the trimmed whole text (defensive; rare).
31
+ function snippetFor(text, matchesLine) {
32
+ const lines = String(text).split('\n');
33
+ const i = lines.findIndex((l) => matchesLine(l));
34
+ if (i === -1) return String(text).trim();
35
+ const from = Math.max(0, i - 1);
36
+ const to = Math.min(lines.length - 1, i + 1);
37
+ return lines.slice(from, to + 1).join('\n').trim();
38
+ }
39
+
40
+ // ── render: one hit → "timestamp · session (or 'this session') · role · snippet" ──
41
+ // The session column collapses to "this session" when the hit is from the caller's active session
42
+ // (opts.session), so a result set reads as scrollback relative to where the operator stands now.
43
+ function renderHit(hit, opts) {
44
+ const active = opts && opts.session;
45
+ const sessionLabel = active && hit.session === active ? 'this session' : hit.session;
46
+ return `${hit.ts} · ${sessionLabel} · ${hit.role} · ${hit.snippet}`;
47
+ }
48
+
49
+ // ── locations (injectable — tests pass a temp root) ───────────────────────────
50
+ // The events dir for an install root, mirroring emit-event.cjs's eventsDir(root).
51
+ function eventsDirOf(root) {
52
+ return path.join(root, '.wrxn', 'events');
53
+ }
54
+
55
+ // Normalize the roots arg to a list of install roots. A string is one root; an array is many. The seam
56
+ // stays pure/injectable — it never resolves cwd itself; the CLI passes the resolved root in.
57
+ function normalizeRoots(roots) {
58
+ if (!roots) return [];
59
+ return Array.isArray(roots) ? roots : [roots];
60
+ }
61
+
62
+ // Every <sid>.jsonl session log in an events dir. Absent dir → [] (no crash), mirroring wiki.cjs.
63
+ function listEventFiles(dir) {
64
+ try {
65
+ return fs
66
+ .readdirSync(dir)
67
+ .filter((n) => n.endsWith('.jsonl'))
68
+ .map((n) => path.join(dir, n));
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ // ── injected-context strip (REUSE-by-replication of memory-synth's #62 sentinel-strip) ──────────
75
+ // REUSE > ADAPT > CREATE: this is COPIED (mirror, not import) from memory-synth.cjs's stripInjectedContext
76
+ // (the slice-1 self-containment convention — the engine replicates findInstallRoot rather than importing,
77
+ // and importing memory-synth would transitively pull in https + child_process, breaching the ADR-0008
78
+ // no-network/no-daemon boundary this engine's dependency test pins). Hook-injected framework-context
79
+ // sentinels — SessionStart's <wrxn-orientation> (which embeds the prior baton), UserPromptSubmit's
80
+ // <synapse-rules>/<recall-surface>/<reference-candidate>, and <system-reminder> harness notes — land
81
+ // verbatim in the transcript. They are framework noise, not conversation, so a hit inside one is NOT a real
82
+ // hit: strip them BEFORE matching. (#62, #84)
83
+ const INJECTED_SENTINELS = ['wrxn-orientation', 'synapse-rules', 'recall-surface', 'reference-candidate', 'system-reminder'];
84
+ // ReDoS bound (#62): the closed-block strip is ~quadratic on a pathological part (many opens, no closes). A
85
+ // real injected block is a few KB; this cap sits far above that yet below the multi-second regex range, so
86
+ // the strip stays effectively linear even when the engine scans whole transcripts.
87
+ const INJECTED_STRIP_MAX = 65536;
88
+
89
+ // Strip hook-injected framework-context blocks from one text part BEFORE matching. Two phases: (1) every
90
+ // well-delimited <tag>…</tag> block anywhere; (2) a part-LEADING unclosed <tag>… (transcript truncated
91
+ // mid-block) to end-of-part — anchored so a sentinel merely MENTIONED mid-prose keeps its tail. PURE and
92
+ // FAIL-OPEN: any fault returns the input unchanged. Byte-faithful copy of memory-synth.cjs (#62).
93
+ function stripInjectedContext(text) {
94
+ try {
95
+ let out = String(text || '');
96
+ if (out.length > INJECTED_STRIP_MAX) return out; // far larger than any real injected block → ordinary content, nothing part-leading to strip.
97
+ for (const tag of INJECTED_SENTINELS) {
98
+ out = out.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, 'g'), '');
99
+ }
100
+ for (const tag of INJECTED_SENTINELS) {
101
+ out = out.replace(new RegExp(`^\\s*<${tag}>[\\s\\S]*$`), '');
102
+ }
103
+ return out;
104
+ } catch {
105
+ return String(text || ''); // a filter fault never breaks the scan
106
+ }
107
+ }
108
+
109
+ // ── secret redaction (REUSE-by-replication of the #39 canonical set) ────────────────────────────
110
+ // The transcript arm derives output from RAW chat, which can echo a credential the operator/agent pasted.
111
+ // Scrub it before any snippet leaves the engine (events are already pre-redacted upstream). The array body
112
+ // below is the ONE canonical secret-shape set, kept BYTE-IDENTICAL to its dream/sync/harvest/memory-synth/
113
+ // sidecar siblings. THIS copy is ENROLLED in adapter-drift-guard.test.cjs's #39 CANON_SITES, so the build
114
+ // fails if it ever drifts from the others — copied, not imported, for the same self-containment reason as
115
+ // stripInjectedContext above. (#39, #84)
116
+ const SECRET_PATTERNS = [
117
+ /AKIA[0-9A-Z]{16}/, // AWS access key id
118
+ /gh[pousr]_[A-Za-z0-9]{20,}/, // GitHub token (ghp_/gho_/ghu_/ghs_/ghr_); {20,} covers the 36-char + CI forms
119
+ /github_pat_[A-Za-z0-9_]{22,}/, // GitHub fine-grained PAT
120
+ /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack token
121
+ /sk-[A-Za-z0-9]{20,}/, // OpenAI-style secret key
122
+ /sk-proj-[A-Za-z0-9_-]{20,}/, // OpenAI project-scoped key (underscore form sk-… misses)
123
+ /AIza[0-9A-Za-z._-]{10,}/, // Google / Gemini API key
124
+ /sk_(?:live|test)_[A-Za-z0-9]{20,}/, // Stripe live/test secret key
125
+ /npm_[A-Za-z0-9]{20,}/, // npm publish / automation token
126
+ /\bey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{3,}\.[A-Za-z0-9_-]{3,}\b/, // JWT (incl. Bearer payloads); the eyJ… header gates it
127
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/, // PEM block (FULL — must precede the header fallback so redaction eats the body)
128
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM header (fallback: a lone/truncated header with no END)
129
+ /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/, // opaque Bearer token (non-JWT)
130
+ /\b[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)\b\s*[:=]\s*\S+/i, // KEY/TOKEN/SECRET/PASSWORD = value
131
+ ];
132
+
133
+ // Redaction form: every shape made global so EVERY occurrence is scrubbed (each clone preserves its own
134
+ // flags and only ADDS g, so detection and redaction never diverge). Mirrors memory-synth.cjs (#39).
135
+ const REDACTIONS = SECRET_PATTERNS.map((re) => new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
136
+
137
+ // Redact common secret shapes from `text`, each match → `[REDACTED]`. PURE; ordinary prose is preserved.
138
+ function redactSecrets(text) {
139
+ let out = String(text || '');
140
+ for (const re of REDACTIONS) out = out.replace(re, '[REDACTED]');
141
+ return out;
142
+ }
143
+
144
+ // ── harness-transcript arm (#84) ──────────────────────────────────────────────
145
+ // The transcript lives OUTSIDE the install, under ~/.claude/projects/<slug>/*.jsonl, where the harness
146
+ // derives <slug> from the project's absolute path. The base is injectable (opts.transcriptsHome) so the
147
+ // engine is testable without touching the operator's real ~/.claude.
148
+ function defaultTranscriptsHome() {
149
+ return path.join(os.homedir(), '.claude', 'projects');
150
+ }
151
+
152
+ // Map an install root to its harness transcript dir <home>/<slug>. The slug mirrors how Claude Code names
153
+ // ~/.claude/projects/<slug>: every NON-alphanumeric char of the absolute root path → '-' (verified against
154
+ // the live dir names). That replacement IS the path-traversal defense — no '/', '\' or '..' can survive it,
155
+ // so a crafted root cannot escape the base. The containment check is belt-and-suspenders and ALSO refuses a
156
+ // degenerate empty slug that would otherwise resolve to the base itself and read EVERY project's transcripts
157
+ // (a cross-project leak). A refusal returns null → the caller degrades loudly to events-only. #84.
158
+ function resolveTranscriptDir(root, home) {
159
+ if (!root || !home) return null;
160
+ const base = path.resolve(String(home));
161
+ const slug = String(root).replace(/[^A-Za-z0-9]/g, '-');
162
+ const dir = path.resolve(base, slug);
163
+ if (dir === base || !dir.startsWith(base + path.sep)) return null; // must sit STRICTLY under the base — never the base, never outside it.
164
+ return dir;
165
+ }
166
+
167
+ // Every <sid>.jsonl transcript in a project's harness dir. Throws on an absent/unreadable dir — the caller
168
+ // catches it to DEGRADE LOUDLY to events-only (so it can tell "arm unavailable" from "arm present, empty").
169
+ function listTranscriptFiles(dir) {
170
+ return fs
171
+ .readdirSync(dir)
172
+ .filter((n) => n.endsWith('.jsonl'))
173
+ .map((n) => path.join(dir, n));
174
+ }
175
+
176
+ // Flatten a transcript record's message.content to plain searchable text. Content is a STRING (a plain
177
+ // user/assistant message) OR a text-block ARRAY (only the `text` blocks carry conversation — `thinking` /
178
+ // `tool_use` / `tool_result` blocks are framework noise, not message content, and are dropped). Any other
179
+ // shape → '' (nothing matchable). #84.
180
+ function transcriptText(rec) {
181
+ const m = (rec && rec.message) || {};
182
+ const c = m.content;
183
+ if (typeof c === 'string') return c;
184
+ if (Array.isArray(c)) {
185
+ return c
186
+ .filter((p) => p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string')
187
+ .map((p) => p.text)
188
+ .join('\n');
189
+ }
190
+ return '';
191
+ }
192
+
193
+ // ── slice-3 flags (#85): validation + parsing ──────────────────────────────────
194
+ // A user-facing input error. The `userFacing` flag lets the CLI print this message cleanly (one line, no
195
+ // Node stack/path) and exit non-zero — the AC's "invalid flag input fails LOUD, never a crash". Bad DATA
196
+ // stays fail-soft (skipped, never thrown); only bad INPUT (a flag value) fails loud.
197
+ function inputError(msg) {
198
+ const e = new Error(msg);
199
+ e.userFacing = true;
200
+ return e;
201
+ }
202
+
203
+ // Validate a --session id. Scoping is a pure exact-match on each record's session field — the value is NEVER
204
+ // used to build a path — so traversal/widening are structurally impossible; this charset guard makes that
205
+ // explicit (real session ids are the harness UUIDs + event sids: alnum, hyphen, underscore) and fails LOUD
206
+ // on a malformed id rather than silently matching nothing.
207
+ const SESSION_ID = /^[A-Za-z0-9_-]+$/;
208
+ function validateSession(raw) {
209
+ const s = String(raw);
210
+ if (!SESSION_ID.test(s)) {
211
+ throw inputError(`chat-search: --session value "${raw}" is not a valid session id (expected letters, digits, '-' or '_')`);
212
+ }
213
+ return s;
214
+ }
215
+
216
+ // Parse a --since value into a threshold epoch-ms; a hit survives when its timestamp is >= the threshold.
217
+ // Accepts the keyword `today` (start of the current UTC day — record stamps are UTC `…Z`) or an ISO-8601
218
+ // date/datetime (via Date.parse). An unparseable value fails LOUD (the AC's invalid-input handling).
219
+ function parseSince(raw) {
220
+ const s = String(raw).trim();
221
+ if (s.toLowerCase() === 'today') {
222
+ const d = new Date();
223
+ d.setUTCHours(0, 0, 0, 0);
224
+ return d.getTime();
225
+ }
226
+ const t = Date.parse(s);
227
+ if (Number.isNaN(t)) {
228
+ throw inputError(`chat-search: --since value "${raw}" is not a date — use "today" or an ISO-8601 date (e.g. 2026-06-26)`);
229
+ }
230
+ return t;
231
+ }
232
+
233
+ // ── --regex ReDoS bound ──────────────────────────────────────────────────────
234
+ // --regex compiles a USER-supplied pattern and runs it over whole transcripts → classic ReDoS surface. Node
235
+ // has no native regex timeout and ADR-0008 forbids a worker/child_process to bound runtime, so the defense is
236
+ // STATIC and applied at COMPILE (before any input is matched):
237
+ // (1) PATTERN-LENGTH CAP — a legitimate search pattern is short ("(deploy|ship).*gate", "\\bbaton\\b"); 200
238
+ // chars is generous for real use yet bounds how much quantifier nesting an adversarial pattern can pack.
239
+ // (2) NESTING-AWARE CATASTROPHIC-SHAPE SCREEN (isCatastrophicRegex) — reject a *quantified group* whose
240
+ // body, through ANY nesting depth, repeats or alternates (a quantifier + * { ? or an alternation |).
241
+ // This is the catastrophic-backtracking (star-height ≥ 2 / overlapping-alternation-under-a-quantifier)
242
+ // family — both FLAT (a+)+, (a|a)+, (?:a*)* AND the NESTED forms a flat regex screen misses because it
243
+ // cannot cross an inner paren: ((a)+)+ (the OWASP example), ((a+))+, ((\w)+)+$, (a(b+)c)+, (a{1,5})+.
244
+ // Plus backreferences (\1..\9), which can backtrack catastrophically — refused outright.
245
+ // It is deliberately CONSERVATIVE: it also rejects the rare-but-safe outer-quantified alternation (foo|bar)+
246
+ // (drop the outer quantifier to search it). It does NOT over-reject ordinary grouping — (foo|bar), (ab)+,
247
+ // ([a+])+, (?:ab)+, a{1,5}, \d{4}-\d{2}-\d{2} all pass.
248
+ // RESIDUAL (stated honestly): a STATIC screen cannot be airtight under the no-timeout/ADR-0008 constraint —
249
+ // it models group/quantifier structure, not match semantics, so it cannot prove a pattern safe, only refuse
250
+ // the known-dangerous shapes. The catastrophic families that actually occur (flat + nested star-height-2,
251
+ // overlapping alternation under a quantifier, backreferences — incl. every reviewed probe) are refused at
252
+ // compile before any input is matched; an exotic construct outside these structural shapes is not modelled.
253
+ const REGEX_PATTERN_MAX = 200;
254
+
255
+ // Linear (O(pattern length)) structural scan: does `p` contain a quantified group whose body repeats or
256
+ // alternates at any nesting depth, or a backreference? Tracks one frame per open group; `complex` = the
257
+ // group's body holds a quantifier/alternation (bubbled up from descendants), so a quantifier on the group's
258
+ // `)` makes it catastrophic. Skips escapes (\w, \(, …) and char-class interiors ([a+]) where metachars are
259
+ // literal, and the `?` of a group prefix ((?:, (?=, (?<…), which is not a quantifier.
260
+ function isCatastrophicRegex(p) {
261
+ const stack = [];
262
+ for (let i = 0; i < p.length; i++) {
263
+ const c = p[i];
264
+ if (c === '\\') {
265
+ const n = p[i + 1];
266
+ if (n >= '1' && n <= '9') return true; // backreference → refuse
267
+ i++; // skip the escaped atom — never structural
268
+ continue;
269
+ }
270
+ if (c === '[') { // character class: every metachar inside is a literal
271
+ i++;
272
+ if (p[i] === '^') i++;
273
+ if (p[i] === ']') i++; // a leading ] is a literal member
274
+ while (i < p.length && p[i] !== ']') {
275
+ if (p[i] === '\\') i++;
276
+ i++;
277
+ }
278
+ continue;
279
+ }
280
+ if (c === '(') {
281
+ stack.push({ complex: false });
282
+ if (p[i + 1] === '?') i++; // group prefix ((?:, (?=, (?<…) — its ? is not a quantifier
283
+ continue;
284
+ }
285
+ if (c === ')') {
286
+ const f = stack.pop() || { complex: false };
287
+ const q = p[i + 1];
288
+ if ((q === '+' || q === '*' || q === '?' || q === '{') && f.complex) return true; // quantified group, repeating body
289
+ if (stack.length) stack[stack.length - 1].complex = stack[stack.length - 1].complex || f.complex; // bubble up any depth
290
+ continue;
291
+ }
292
+ if ((c === '|' || c === '+' || c === '*' || c === '?' || c === '{') && stack.length) {
293
+ stack[stack.length - 1].complex = true; // a quantifier/alternation token in the current group's body
294
+ }
295
+ }
296
+ return false;
297
+ }
298
+
299
+ // Compile a user-supplied --regex pattern. Case-sensitive, no g/y flag, so .test() is stateless across the
300
+ // thousands of calls the scan makes. A too-long / catastrophic / malformed pattern fails LOUD (the AC's
301
+ // invalid-input handling) — the operator sees one clean line, never a stack or a multi-second hang.
302
+ function compileUserRegex(pattern) {
303
+ const p = String(pattern);
304
+ if (p.length > REGEX_PATTERN_MAX) {
305
+ throw inputError(`chat-search: --regex pattern too long (max ${REGEX_PATTERN_MAX} chars)`);
306
+ }
307
+ if (isCatastrophicRegex(p)) {
308
+ throw inputError('chat-search: --regex pattern rejected — a quantified group whose body repeats or alternates (or a backreference) risks catastrophic backtracking (ReDoS); simplify the pattern');
309
+ }
310
+ try {
311
+ return new RegExp(p);
312
+ } catch (err) {
313
+ throw inputError(`chat-search: --regex pattern is not a valid regular expression (${err.message})`);
314
+ }
315
+ }
316
+
317
+ // ── the engine seam ───────────────────────────────────────────────────────────
318
+ // searchConversationalLog(query, opts, roots): scan BOTH arms across the given roots' sessions and return
319
+ // the turns that match `query` — a case-insensitive substring by default, or (opts.regex) the compiled
320
+ // pattern. opts also carries the slice-3 filters: opts.session scopes to one session, opts.since is a
321
+ // timestamp floor; both are applied per-record so they compose with the arms, recency, and dedup. A bad
322
+ // flag value (invalid/catastrophic regex, unparseable --since, unsafe --session) throws a user-facing
323
+ // error (fail loud). Each hit is a structured record { ts, session, role, snippet }.
324
+ function searchConversationalLog(query, opts, roots) {
325
+ const q = String(query == null ? '' : query);
326
+ const needle = q.toLowerCase();
327
+ const hits = [];
328
+ const seen = new Set(); // a prompt present in BOTH arms (same session+ts+text) must surface only once.
329
+ let degraded = false; // set when the transcript arm could not be consulted → loud events-only degrade (#84).
330
+
331
+ // ── slice-3 filters (#85): each is an opts field applied per-record inside consider(), so it composes
332
+ // automatically with BOTH arms, the recency sort, and the cross-arm dedup. --session scopes to one session. ──
333
+ const sessionScope = opts && opts.session != null ? validateSession(opts.session) : null;
334
+ const sinceThreshold = opts && opts.since != null ? parseSince(opts.since) : null; // --since: epoch-ms floor
335
+ const useRegex = !!(opts && opts.regex);
336
+ const re = useRegex ? compileUserRegex(q) : null; // --regex: compile once (throws loud on a bad/dangerous pattern)
337
+ // The one matcher used for BOTH the whole-text scan gate and the per-line snippet — substring or regex.
338
+ const lineMatches = useRegex ? (s) => re.test(String(s)) : (s) => String(s).toLowerCase().includes(needle);
339
+
340
+ // Push a hit when `text` contains the needle and this (session, timestamp, text) triple is new. `text` is
341
+ // the FULL message text already made surface-safe by its arm (events are pre-redacted; transcript turns are
342
+ // injected-stripped + secret-redacted before they reach here), so the snippet it yields is safe to render.
343
+ function consider(ts, session, role, text) {
344
+ if (sessionScope != null && session !== sessionScope) return; // --session: exact-match a single session
345
+ if (typeof text !== 'string' || !lineMatches(text)) return; // --regex pattern, else case-insensitive substring
346
+ if (sinceThreshold != null && !(Date.parse(ts) >= sinceThreshold)) return; // --since: drop hits before the floor (an undatable ts → NaN → dropped)
347
+ const key = `${session}${ts}${text}`; // the AC dedup key — NUL-joined so the parts can't bleed.
348
+ if (seen.has(key)) return;
349
+ seen.add(key);
350
+ hits.push({ ts, session, role, snippet: snippetFor(text, lineMatches) });
351
+ }
352
+
353
+ for (const root of normalizeRoots(roots)) {
354
+ // ── event-log arm (slice 1): pre-redacted user prompts under <root>/.wrxn/events ──
355
+ for (const file of listEventFiles(eventsDirOf(root))) {
356
+ let lines;
357
+ try {
358
+ lines = fs.readFileSync(file, 'utf8').split('\n');
359
+ } catch {
360
+ continue; // an unreadable entry (EISDIR dir, EACCES, ENOENT/TOCTOU, broken symlink) → skip it, keep scanning
361
+ }
362
+ for (const line of lines) {
363
+ if (!line.trim()) continue;
364
+ let rec;
365
+ try {
366
+ rec = JSON.parse(line);
367
+ } catch {
368
+ continue; // skip a malformed line, never crash the scan
369
+ }
370
+ if (!rec || rec.kind !== 'prompt' || typeof rec.text !== 'string') continue; // ONLY prompt records match — a non-prompt (e.g. tool) record is skipped even if it carries a stray text field (#87)
371
+ consider(rec.ts, rec.sid, roleFromKind(rec.kind), rec.text); // events are pre-redacted upstream — pass through as-is.
372
+ }
373
+ }
374
+
375
+ // ── harness-transcript arm (#84): assistant turns + full user/assistant message content ──
376
+ const home = (opts && opts.transcriptsHome) || defaultTranscriptsHome();
377
+ const tdir = resolveTranscriptDir(root, home);
378
+ let tfiles = null;
379
+ if (tdir) {
380
+ try {
381
+ tfiles = listTranscriptFiles(tdir); // a present dir yields an array (possibly empty); absent/unreadable throws.
382
+ } catch {
383
+ /* absent / unreadable transcript dir → tfiles stays null → degrade below */
384
+ }
385
+ }
386
+ // tfiles null ⇒ the arm could NOT be consulted (dir refused by the slug guard, or absent/unreadable) →
387
+ // loud events-only degrade. A present-but-empty dir yields [] (arm reachable, simply nothing) → NO degrade.
388
+ if (!tfiles) {
389
+ degraded = true;
390
+ } else {
391
+ for (const file of tfiles) {
392
+ let lines;
393
+ try {
394
+ lines = fs.readFileSync(file, 'utf8').split('\n');
395
+ } catch {
396
+ continue; // an unreadable transcript entry → skip it, keep scanning (never crash)
397
+ }
398
+ for (const line of lines) {
399
+ if (!line.trim()) continue;
400
+ let rec;
401
+ try {
402
+ rec = JSON.parse(line);
403
+ } catch {
404
+ continue; // skip a malformed transcript line, never crash the scan
405
+ }
406
+ if (!rec || (rec.type !== 'user' && rec.type !== 'assistant')) continue; // only user/assistant turns; unknown line types (summary/system/…) skipped
407
+ // hygiene pipeline: flatten → strip injected framework context (a block holding the term is not a
408
+ // hit) → redact secrets (raw chat can echo a credential; scrub BEFORE it can surface in a snippet).
409
+ const text = redactSecrets(stripInjectedContext(transcriptText(rec)));
410
+ consider(rec.timestamp, rec.sessionId, rec.type, text);
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ // Recency-first: newest timestamp on top. ISO-8601 stamps parse cleanly; an unparseable ts sorts last.
417
+ hits.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
418
+
419
+ const found = hits.length > 0;
420
+ // Loud degrade (#84): when the transcript arm could not be consulted, say so on its own line so the
421
+ // operator knows results are EVENTS-ONLY (no assistant turns, no full message content) — never silent.
422
+ const head = found
423
+ ? hits.map((h) => renderHit(h, opts)).join('\n')
424
+ : `chat-search: nothing found for "${q}" in the conversational log.`;
425
+ const rendered = degraded
426
+ ? `${head}\nchat-search: transcript arm unavailable — showing event-log results only.`
427
+ : head;
428
+
429
+ return { query: q, total: hits.length, found, degraded, hits, rendered };
430
+ }
431
+
432
+ // ── CLI: node .wrxn/chat-search.cjs <term...> [--root <dir>] [--session <id>] [--since <when>] [--regex] ──
433
+ // The operator-invocable surface (/chat-search <term>). Resolves the install root by walking up to the
434
+ // wrxn.install.json receipt (mirrors wiki.cjs), or honors a --root override (tests). Prints the rendered
435
+ // result and exits 0 — a nothing-found result is a normal outcome, never an error exit.
436
+
437
+ // Mirrors wiki.cjs / emit-event.cjs findInstallRoot (no kernel-lib import in a shipped file).
438
+ function findInstallRoot(start) {
439
+ let dir = start || process.env.CLAUDE_PROJECT_DIR || process.cwd();
440
+ for (let i = 0; i < 12; i++) {
441
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
442
+ const up = path.dirname(dir);
443
+ if (up === dir) break;
444
+ dir = up;
445
+ }
446
+ return null;
447
+ }
448
+
449
+ // Value of a --name <value> flag. A following token that is itself a --flag (or a missing value) is NOT
450
+ // consumed as the value — so `--session --regex` can't silently swallow --regex as the session id.
451
+ function flag(name) {
452
+ const i = process.argv.indexOf(`--${name}`);
453
+ if (i < 0) return undefined;
454
+ const val = process.argv[i + 1];
455
+ return val == null || val.startsWith('--') ? undefined : val;
456
+ }
457
+
458
+ // Presence of a boolean --name flag (e.g. --regex), which carries no value.
459
+ function hasFlag(name) {
460
+ return process.argv.includes(`--${name}`);
461
+ }
462
+
463
+ // Positional search terms: argv from index 2 up to the first --flag.
464
+ function positionals() {
465
+ const out = [];
466
+ for (let i = 2; i < process.argv.length; i++) {
467
+ if (process.argv[i].startsWith('--')) break;
468
+ out.push(process.argv[i]);
469
+ }
470
+ return out;
471
+ }
472
+
473
+ function main() {
474
+ const terms = positionals();
475
+ if (terms.length === 0) {
476
+ process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]\n');
477
+ process.exit(2);
478
+ }
479
+ const root = flag('root') || findInstallRoot();
480
+ if (!root) {
481
+ process.stderr.write('chat-search: cannot resolve the install root — run inside a wrxn install or pass --root <dir>\n');
482
+ process.exit(2);
483
+ }
484
+ // Wire the slice-3 flags (#85) into engine opts. The engine validates them (it throws a clean, user-facing
485
+ // error on a bad regex / unparseable --since); the CLI catch below prints that one line and exits non-zero.
486
+ const opts = {};
487
+ const session = flag('session');
488
+ if (session !== undefined) opts.session = session;
489
+ const since = flag('since');
490
+ if (since !== undefined) opts.since = since;
491
+ if (hasFlag('regex')) opts.regex = true;
492
+
493
+ let result;
494
+ try {
495
+ result = searchConversationalLog(terms.join(' '), opts, root);
496
+ } catch (err) {
497
+ if (err && err.userFacing) {
498
+ // invalid flag input (bad/catastrophic regex, unparseable --since): fail LOUD with the one clean line
499
+ // the engine already built — never a Node stack or path — and exit non-zero (usage error).
500
+ process.stderr.write(err.message + '\n');
501
+ process.exit(2);
502
+ }
503
+ throw err; // an unexpected fault → the entrypoint wrap turns it into a clean path-free diagnostic
504
+ }
505
+ process.stdout.write(result.rendered + '\n');
506
+ process.exit(0);
507
+ }
508
+
509
+ // Belt-and-suspenders fail-loud (mirrors emit-event.cjs's entrypoint wrap): the per-file read and root
510
+ // resolution are already guarded, so main() should not throw — but if any residual fault escapes, exit
511
+ // with a clean ONE-LINE diagnostic (a path-free error code only — never a Node stack or absolute path).
512
+ if (require.main === module) {
513
+ try {
514
+ main();
515
+ } catch (err) {
516
+ const code = err && err.code ? ` (${err.code})` : '';
517
+ process.stderr.write(`chat-search: search failed unexpectedly${code}\n`);
518
+ process.exit(1);
519
+ }
520
+ }
521
+
522
+ module.exports = { searchConversationalLog, renderHit, resolveTranscriptDir };