@gcunharodrigues/wrxn 0.13.2 → 0.14.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/bin/wrxn.cjs CHANGED
@@ -393,7 +393,16 @@ async function main(argv) {
393
393
  process.stderr.write(`wrxn: cannot read issue: ${err.message}\n`);
394
394
  return 2;
395
395
  }
396
- process.stdout.write(JSON.stringify(executor.buildDispatchSpec(issueText, type), null, 2) + '\n');
396
+ // S5 dispatch-RAG (#24): seed a recon_find from the issue's referenced symbols and inject the prose
397
+ // hits as "Relevant prior knowledge" so the executor starts warm. The door is REUSED from the
398
+ // package-layer brain client (lib/brain.cjs) — prose-filtered + limit-capped there. Fully fail-open:
399
+ // an unreachable / cold Brain (brain.query rejects) leaves the spec byte-identical to today.
400
+ const priorKnowledge = await executor
401
+ .retrievePriorKnowledge(issueText, {
402
+ find: (seed) => brain.query(seed, { type: 'prose', limit: executor.PRIOR_KNOWLEDGE_CAP }, {}).then((r) => r.hits),
403
+ })
404
+ .catch(() => []);
405
+ process.stdout.write(JSON.stringify(executor.buildDispatchSpec(issueText, type, priorKnowledge), null, 2) + '\n');
397
406
  return 0;
398
407
  }
399
408
 
package/lib/executor.cjs CHANGED
@@ -8,7 +8,9 @@
8
8
  // executors only `devops` may pass it. The live LLM execution is out of scope — the harness is the
9
9
  // proven contract.
10
10
  //
11
- // Pure data transforms (no I/O); the CLI (bin/wrxn.cjs dispatch) reads/writes files around them.
11
+ // Pure data transforms (no I/O of their own); the CLI (bin/wrxn.cjs dispatch) reads/writes files around
12
+ // them. S5 adds retrievePriorKnowledge — an async retrieval orchestrator whose recon_find door is an
13
+ // INJECTED dependency (the CLI wires the real lib/brain.cjs door), so the module itself stays I/O-free.
12
14
 
13
15
  const BUILD_SKILL = '.claude/skills/tdd/SKILL.md';
14
16
 
@@ -18,6 +20,13 @@ const BUILDER_REQUIRED = ['issueId', 'status', 'redTest', 'greenCommit', 'typesC
18
20
  const GENERIC_REQUIRED = ['issueId', 'status', 'artifact', 'pushed', 'summary'];
19
21
  const STATUSES = ['completed', 'blocked'];
20
22
 
23
+ // S5 dispatch-RAG (#24): the dispatch shell may inject prior knowledge recalled from the Brain so an
24
+ // executor starts warm. Capped to a small top-N — a builder wants a few high-signal references, not a
25
+ // digest. The same cap seeds the shell's recon_find limit (single source of truth).
26
+ const PRIOR_KNOWLEDGE_CAP = 5;
27
+ const PRIOR_KNOWLEDGE_NOTE =
28
+ 'Relevant prior knowledge recalled from your Brain for this issue — read it before re-deriving or re-asking the operator.';
29
+
21
30
  // The executor registry — one entry per type. `skill: null` means the loop is a GLOBAL slash-skill
22
31
  // with no local file (code-review / security-review / the devops push), so the spec carries explicit
23
32
  // `instructions` instead (the subagent has no Skill tool — it cannot /invoke). `canPush` gates the
@@ -157,16 +166,86 @@ function constraintsFor(def) {
157
166
  ];
158
167
  }
159
168
 
169
+ // ── S5 dispatch-RAG: render injected prior knowledge into a capped section (PURE) ────────────────────
170
+ //
171
+ // The dispatch shell retrieves prose knowledge from the warm Brain (a recon_find seeded by the issue's
172
+ // referenced symbols) and injects it here. Each item is a recon prose hit ({ name, file }) OR a
173
+ // pre-rendered string; this normalizes every item to one clean line ("<title> — <path>"), dedups, and
174
+ // caps at PRIOR_KNOWLEDGE_CAP. TOTAL + defensive: a non-array / empty / all-junk input → [] (the no-op:
175
+ // buildDispatchSpec then omits the section, keeping the spec byte-identical to pre-S5). PURE — no I/O.
176
+ function priorKnowledgeLine(item) {
177
+ if (typeof item === 'string') return item.replace(/\s+/g, ' ').trim();
178
+ if (item && typeof item === 'object') {
179
+ const name = String(item.name || '').replace(/\s+/g, ' ').trim();
180
+ const file = String(item.file || '').trim();
181
+ if (name && file) return `${name} — ${file}`;
182
+ return name || file || '';
183
+ }
184
+ return '';
185
+ }
186
+
187
+ function renderPriorKnowledge(priorKnowledge) {
188
+ const list = Array.isArray(priorKnowledge) ? priorKnowledge : [];
189
+ const seen = new Set();
190
+ const items = [];
191
+ for (const it of list) {
192
+ const line = priorKnowledgeLine(it);
193
+ if (!line || seen.has(line)) continue;
194
+ seen.add(line);
195
+ items.push(line);
196
+ if (items.length >= PRIOR_KNOWLEDGE_CAP) break;
197
+ }
198
+ return items;
199
+ }
200
+
201
+ // ── S5 dispatch-RAG: seed a recon_find query from the issue's referenced symbols (PURE) ──────────────
202
+ //
203
+ // The dispatch shell needs a structural seed for the Brain. This REUSES slice F's buildStructuralQuery
204
+ // IDEA — basename-if-path, tokenize on non-alphanumerics, dedup case-insensitively, cap — but keyed off
205
+ // the ISSUE's referenced symbols rather than the session .touched paths. The "referenced symbols/paths"
206
+ // are the issue's single-token backtick code spans (function names, identifiers, file paths); multi-word
207
+ // backtick prose phrases are dropped (not symbols → stop-word noise). No backtick symbol → '' (the no-op
208
+ // seed: the shell then injects nothing). This transform is DUPLICATED from the payload hook
209
+ // recall-surface.cjs (buildStructuralQuery) ON PURPOSE — that hook is self-contained node-stdlib-only and
210
+ // cannot be imported by package code (the same cross-install-boundary trade lib/brain.cjs documents for
211
+ // the discovery contract). PURE — no I/O. (basename via lastIndexOf keeps this module dependency-free.)
212
+ const MAX_QUERY_CHARS = 512; // recon_find seed budget (mirrors recall-surface)
213
+
214
+ function buildIssueQuery(issueText) {
215
+ const text = String(issueText || '');
216
+ const spans = text.match(/`([^`\n]+)`/g) || [];
217
+ const seen = new Set();
218
+ const tokens = [];
219
+ for (const span of spans) {
220
+ const raw = span.slice(1, -1).trim();
221
+ if (!raw || /\s/.test(raw)) continue; // a referenced symbol/path is a single token, never a prose phrase
222
+ const base = raw.includes('/') ? raw.slice(raw.lastIndexOf('/') + 1) : raw; // a path reduces to its basename
223
+ const bare = base.replace(/\.[^.]+$/, ''); // strip a trailing extension (lib/executor.cjs → executor)
224
+ for (const t of bare.split(/[^A-Za-z0-9]+/)) {
225
+ const tok = t.trim();
226
+ if (!tok) continue;
227
+ const key = tok.toLowerCase();
228
+ if (seen.has(key)) continue;
229
+ seen.add(key);
230
+ tokens.push(tok);
231
+ }
232
+ }
233
+ return tokens.join(' ').slice(0, MAX_QUERY_CHARS);
234
+ }
235
+
160
236
  /**
161
237
  * Build the dispatch spec for an executor of `executorType` (default 'builder') from an issue. The
162
238
  * spec is the complete, self-contained order the subagent follows: which skill to read+follow (or
163
239
  * the explicit instructions for a global-only skill), the issue ACs, isolation, the boundary
164
- * constraints, and the structured reportSchema.
240
+ * constraints, and the structured reportSchema. `priorKnowledge` (optional, injected by the dispatch
241
+ * shell) is rendered as a capped "Relevant prior knowledge" section ONLY when non-empty — with nothing
242
+ * injected the spec is byte-identical to pre-S5 (AC-2). Stays PURE (no I/O).
165
243
  */
166
- function buildDispatchSpec(issueText, executorType = 'builder') {
244
+ function buildDispatchSpec(issueText, executorType = 'builder', priorKnowledge = []) {
167
245
  const def = EXECUTORS[executorType];
168
246
  if (!def) throw new Error(`unknown executor type: ${executorType} (one of ${EXECUTOR_TYPES.join(', ')})`);
169
247
  const issue = parseIssue(issueText);
248
+ const priorItems = renderPriorKnowledge(priorKnowledge);
170
249
  return {
171
250
  executor: executorType,
172
251
  issue: { id: issue.id, title: issue.title },
@@ -175,12 +254,37 @@ function buildDispatchSpec(issueText, executorType = 'builder') {
175
254
  procedure: def.procedure ? def.procedure.slice() : deriveProcedure(def),
176
255
  artifact: def.artifact,
177
256
  acceptanceCriteria: issue.acceptanceCriteria,
257
+ // Present ONLY when the shell injected knowledge — with none, the spec is byte-identical to pre-S5.
258
+ ...(priorItems.length ? { priorKnowledge: { note: PRIOR_KNOWLEDGE_NOTE, items: priorItems } } : {}),
178
259
  isolation: def.isolation,
179
260
  constraints: constraintsFor(def),
180
261
  reportSchema: { required: [...def.required], statuses: [...STATUSES] },
181
262
  };
182
263
  }
183
264
 
265
+ // ── S5 dispatch-RAG: the retrieval orchestrator (IO injected; the impure shell wires the real door) ──
266
+ //
267
+ // Policy layer between the issue and the warm Brain: seed → find → cap, FAIL-OPEN. `find(seed) -> hits`
268
+ // is INJECTED (the IO seam, like brain.query's transport): bin/wrxn.cjs wires it to lib/brain.cjs
269
+ // (recon_find with type:'prose', so the hits are already prose-filtered) — keeping this unit-testable
270
+ // with no live recon. No referenced symbols → empty seed → the door is never queried → []. Any fault
271
+ // (unreachable door, non-200, malformed body) → [] — dispatch then proceeds exactly as today
272
+ // (byte-identical spec). The cap is PRIOR_KNOWLEDGE_CAP (also the shell's recon_find limit);
273
+ // renderPriorKnowledge re-caps defensively. Never throws.
274
+ async function retrievePriorKnowledge(issueText, { find } = {}) {
275
+ if (typeof find !== 'function') return [];
276
+ const seed = buildIssueQuery(issueText);
277
+ if (!seed) return []; // no referenced symbols → nothing to retrieve (fail-open)
278
+ let hits;
279
+ try {
280
+ hits = await find(seed);
281
+ } catch {
282
+ return []; // unreachable recon / non-200 / malformed → fail-open
283
+ }
284
+ if (!Array.isArray(hits) || !hits.length) return []; // empty Brain → nothing injected
285
+ return hits.slice(0, PRIOR_KNOWLEDGE_CAP);
286
+ }
287
+
184
288
  /**
185
289
  * Validate an executor's structured report against the contract + the boundary gates for its type.
186
290
  * Returns { ok, errors }. The push gate is type-aware: a report claiming pushed=true is a boundary
@@ -238,4 +342,7 @@ module.exports = {
238
342
  EXECUTORS,
239
343
  EXECUTOR_TYPES,
240
344
  STATUSES,
345
+ PRIOR_KNOWLEDGE_CAP,
346
+ buildIssueQuery,
347
+ retrievePriorKnowledge,
241
348
  };
package/lib/install.cjs CHANGED
@@ -86,6 +86,19 @@ function init(opts) {
86
86
  ensureGitignoreLine(target, '.recon-wrxn/');
87
87
  // the recall-surface hook writes per-install access-recency state here (harvest-08) — runtime, not committed.
88
88
  ensureGitignoreLine(target, '.wrxn/reinforce.json');
89
+ // the recall-surface hook also writes the per-session surfaced-log here (#12 S1) — runtime STATE, not committed.
90
+ ensureGitignoreLine(target, '.wrxn/surfaced.json');
91
+ // session-start stamps a per-session start-HEAD baseline here and the session-end shell keeps the
92
+ // coalesced reward sidecar here (#13 S2) — runtime STATE (survives `wrxn update`), never committed.
93
+ ensureGitignoreLine(target, '.wrxn/baseline/');
94
+ ensureGitignoreLine(target, '.wrxn/reward.json');
95
+ // the emit-event hook appends verbatim (secret-redacted) prompt text to .wrxn/events/<sid>.jsonl (#35) —
96
+ // the only sink persisting raw prompt text. Ignore the whole dir so captured prompts are never committed
97
+ // (#38 F1); the shipped .gitkeep only makes init create the dir on disk, nothing in it is ever tracked.
98
+ ensureGitignoreLine(target, '.wrxn/events/');
99
+ // the memory-synth writes one outcome line per synth run to `.wrxn/continuity/.synth.log` (synth-handoff-fix-01)
100
+ // — install runtime state (never shipped in the payload), so keep it out of version control.
101
+ ensureGitignoreLine(target, '.wrxn/continuity/.synth.log');
89
102
  // the memory-synth gemini fallback reads GEMINI_API_KEY from `.env` — ignore it so the secret is
90
103
  // never committed (the synth's doc-comment calls it the install's gitignored `.env`).
91
104
  ensureGitignoreLine(target, '.env');
package/lib/release.cjs CHANGED
@@ -48,4 +48,16 @@ function parseLog(raw) {
48
48
  return String(raw).split('\0').map((s) => s.trim()).filter(Boolean);
49
49
  }
50
50
 
51
- module.exports = { shouldRelease, parseLog };
51
+ /**
52
+ * decidePublish({ typeRelease, versionOnNpm }) → boolean. The publish gate (#43): publish when the
53
+ * merged commits warrant a type release OR package.json.version is not yet on npm. Lifting the
54
+ * version-vs-npm guard ABOVE the type gate means a version that landed on `main` via a non-release
55
+ * commit (e.g. `chore(release):`) still reaches npm. Pure — the `npm view` probe stays in the
56
+ * workflow; this just combines the two booleans. When the version is already on npm and no type
57
+ * release applies, it returns false — preserving idempotent re-runs.
58
+ */
59
+ function decidePublish({ typeRelease, versionOnNpm } = {}) {
60
+ return Boolean(typeRelease) || !versionOnNpm;
61
+ }
62
+
63
+ module.exports = { shouldRelease, parseLog, decidePublish };
package/manifest.json CHANGED
@@ -83,6 +83,31 @@
83
83
  "class": "managed",
84
84
  "profile": "project"
85
85
  },
86
+ {
87
+ "path": ".claude/hooks/sidecar.cjs",
88
+ "class": "managed",
89
+ "profile": "project"
90
+ },
91
+ {
92
+ "path": ".claude/hooks/emit-event.cjs",
93
+ "class": "managed",
94
+ "profile": "project"
95
+ },
96
+ {
97
+ "path": ".claude/hooks/prune.cjs",
98
+ "class": "managed",
99
+ "profile": "project"
100
+ },
101
+ {
102
+ "path": ".claude/hooks/reward.cjs",
103
+ "class": "managed",
104
+ "profile": "project"
105
+ },
106
+ {
107
+ "path": ".claude/hooks/session-end-reward.cjs",
108
+ "class": "managed",
109
+ "profile": "project"
110
+ },
86
111
  {
87
112
  "path": ".claude/hooks/reference-detect.cjs",
88
113
  "class": "managed",
@@ -478,6 +503,11 @@
478
503
  "class": "state",
479
504
  "profile": "project"
480
505
  },
506
+ {
507
+ "path": ".wrxn/events/.gitkeep",
508
+ "class": "state",
509
+ "profile": "project"
510
+ },
481
511
  {
482
512
  "path": ".wrxn/dream.cjs",
483
513
  "class": "managed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.13.2",
3
+ "version": "0.14.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"
@@ -16,7 +16,7 @@
16
16
  "test": "node --test --require ./test/setup.cjs"
17
17
  },
18
18
  "dependencies": {
19
- "recon-wrxn": "6.0.0-wrxn.8"
19
+ "recon-wrxn": "6.0.0-wrxn.9"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20"
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN emit-event hook — the metadata-grade session event source (C2 / kernel #35). Wired on BOTH
5
+ // UserPromptSubmit and PostToolUse, it appends one JSON record per line to .wrxn/events/<sid>.jsonl:
6
+ // · a `prompt` record (the user prompt, SECRET-REDACTED) per UserPromptSubmit, and
7
+ // · a SKELETON `tool` record (tool name + a target only) per PostToolUse.
8
+ // This FREEZES the event-JSONL contract the recon-wrxn ② analyzer consumes: { ts, sid, kind, ... } with
9
+ // kind ∈ { prompt, tool }. It is an INDEXED SOURCE (a file recon reads), never a write API.
10
+ //
11
+ // PRIVACY-CRITICAL: a tool record carries ONLY the tool name + a target (e.g. the file path). It NEVER
12
+ // reads the tool input wholesale, the tool response/output, file contents, or a command — by construction
13
+ // the only fields ever copied are tool_name and tool_input.file_path (itself redacted, defence-in-depth).
14
+ //
15
+ // Reuse, do not reinvent: prompt redaction is the sibling sidecar.cjs's redactSecrets (the one source of
16
+ // secret shapes) — the same sibling-require posture as recall-surface.cjs. SPLIT: pure record builders
17
+ // (clock + session id INJECTED) + an IO append shell. APPEND + FAIL-OPEN: any redaction or write fault is
18
+ // swallowed so the emit NEVER throws and never blocks the prompt or the tool call.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { redactSecrets } = require('./sidecar.cjs'); // reuse the existing secret-shape primitive (one source of truth)
23
+
24
+ // safeSessionId — the session-id → filesystem-safe slug. Replicated to match code-intel-push's safeId /
25
+ // recall-surface's safeSessionId BYTE-FOR-BYTE (same discipline that duplicates the sanitizer across the
26
+ // install-only modules): a crafted sid (slashes, `..`) collapses to `[a-z0-9-]` so the event filename can
27
+ // never escape .wrxn/events/.
28
+ function safeSessionId(sid) {
29
+ return String(sid || 'session')
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '')
33
+ .slice(0, 48) || 'session';
34
+ }
35
+
36
+ // An ISO-8601 stamp from the INJECTED clock (ms-epoch or Date; default real time). ISO so prune's
37
+ // Date.parse(record.ts) ages event records exactly as it ages the dream/sync/harvest trails.
38
+ function isoStamp(now) {
39
+ const ms = Number.isFinite(now) ? now : now instanceof Date ? now.getTime() : Date.now();
40
+ return new Date(ms).toISOString();
41
+ }
42
+
43
+ // ── pure record builders (clock + sid injected) ──────────────────────────────────────
44
+
45
+ // A `prompt` record: the user prompt, SECRET-REDACTED. Frozen key set/order: { ts, sid, kind, text }.
46
+ function buildPromptRecord({ ts, sid, prompt }) {
47
+ return { ts, sid, kind: 'prompt', text: redactSecrets(prompt) };
48
+ }
49
+
50
+ // A `tool` record: SKELETON ONLY. Frozen key set/order: { ts, sid, kind, tool, target }. `tool` is the
51
+ // tool name; `target` is a short identifier (the file path) — redacted as defence-in-depth so even a
52
+ // crafted secret-shaped path cannot land. NOTHING else is ever carried (see eventToRecord's extraction).
53
+ function buildToolRecord({ ts, sid, tool, target }) {
54
+ return { ts, sid, kind: 'tool', tool: String(tool || ''), target: redactSecrets(target) };
55
+ }
56
+
57
+ // Dispatch a raw hook event to the record to write, or null when there is nothing to record. The sid is
58
+ // SANITIZED into the record (so record.sid always agrees with the <sid>.jsonl filename), and the clock is
59
+ // injected. A UserPromptSubmit event (a non-empty string `prompt`) → a prompt record; a PostToolUse event
60
+ // (a `tool_name`) → a SKELETON tool record. PRIVACY-CRITICAL: from a tool event the ONLY fields ever read
61
+ // are tool_name and tool_input.file_path — never tool_input wholesale, a command, file contents, or the
62
+ // tool_response/output. A tool with no string file_path records an empty target (its input stays unseen).
63
+ function eventToRecord(event, { now } = {}) {
64
+ if (!event || typeof event !== 'object') return null;
65
+ const ts = isoStamp(now);
66
+ const sid = safeSessionId(event.session_id);
67
+ if (typeof event.prompt === 'string' && event.prompt.trim()) {
68
+ return buildPromptRecord({ ts, sid, prompt: event.prompt });
69
+ }
70
+ if (typeof event.tool_name === 'string' && event.tool_name) {
71
+ const ti = event.tool_input;
72
+ const target = ti && typeof ti.file_path === 'string' ? ti.file_path : '';
73
+ return buildToolRecord({ ts, sid, tool: event.tool_name, target });
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // Walk up from event.cwd / CLAUDE_PROJECT_DIR / cwd to the install root carrying wrxn.install.json.
79
+ // Replicated across the install-only hooks (no kernel-lib import). Returns null when not inside an install.
80
+ function findInstallRoot(startDir) {
81
+ let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
82
+ for (let i = 0; i < 12; i++) {
83
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
84
+ const up = path.dirname(dir);
85
+ if (up === dir) break;
86
+ dir = up;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // ── IO shell: append one JSON line to .wrxn/events/<sid>.jsonl ────────────────────────
92
+
93
+ // The per-session event log. The filename is built from the SANITIZED sid, so a crafted session id can
94
+ // never escape .wrxn/events/ (defence-in-depth: eventToRecord already sanitizes record.sid, and this
95
+ // re-sanitizes — sanitizing an already-safe slug is idempotent).
96
+ function eventsDir(root) {
97
+ return path.join(root, '.wrxn', 'events');
98
+ }
99
+ function eventFile(root, sid) {
100
+ return path.join(eventsDir(root), `${safeSessionId(sid)}.jsonl`);
101
+ }
102
+
103
+ // Append a record as one compact JSON line. Returns true on write, false on any fail-open path. APPEND
104
+ // (never rewrite) so concurrent same-session hooks only ever add lines. FAIL-OPEN: a write fault is
105
+ // swallowed — the emit must never throw into the prompt/tool path.
106
+ function appendEvent(root, record) {
107
+ try {
108
+ if (!root || !record || typeof record !== 'object') return false;
109
+ fs.mkdirSync(eventsDir(root), { recursive: true });
110
+ fs.appendFileSync(eventFile(root, record.sid), JSON.stringify(record) + '\n');
111
+ return true;
112
+ } catch {
113
+ return false; // best-effort: a write fault never blocks the prompt or the tool call
114
+ }
115
+ }
116
+
117
+ // Dispatch a raw hook event to its record and append it. Returns true on write, false otherwise. Wholly
118
+ // FAIL-OPEN: a redaction or write fault is swallowed and never throws.
119
+ function emitEvent(root, event, { now } = {}) {
120
+ try {
121
+ const record = eventToRecord(event, { now });
122
+ if (!record) return false;
123
+ return appendEvent(root, record);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ // ── entrypoint ────────────────────────────────────────────────────────────────────────
130
+ //
131
+ // Contract: a UserPromptSubmit or PostToolUse event JSON on stdin → an empty `{}` envelope on stdout
132
+ // (exit 0). This is a pure SIDE-EFFECT hook: it injects no context, it only appends an event record.
133
+ // Wholly fail-open — any fault (unparseable stdin, no install, write fault) still prints `{}` and exits 0,
134
+ // so it can never block the prompt or the tool call.
135
+ function done() {
136
+ process.stdout.write('{}');
137
+ process.exit(0);
138
+ }
139
+
140
+ function main() {
141
+ let event = {};
142
+ try {
143
+ const stdin = fs.readFileSync(0, 'utf8');
144
+ if (stdin.trim()) event = JSON.parse(stdin);
145
+ } catch {
146
+ return done(); // unparseable stdin → no-op
147
+ }
148
+ let root = null;
149
+ try {
150
+ root = findInstallRoot(event && event.cwd);
151
+ } catch {
152
+ root = null;
153
+ }
154
+ try {
155
+ if (root) emitEvent(root, event, {});
156
+ } catch {
157
+ /* fail-open: an emit fault never blocks the prompt or the tool call */
158
+ }
159
+ return done();
160
+ }
161
+
162
+ if (require.main === module) {
163
+ try {
164
+ main();
165
+ } catch {
166
+ done();
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ eventToRecord,
172
+ buildPromptRecord,
173
+ buildToolRecord,
174
+ safeSessionId,
175
+ isoStamp,
176
+ eventsDir,
177
+ eventFile,
178
+ appendEvent,
179
+ emitEvent,
180
+ findInstallRoot,
181
+ };