@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 +10 -1
- package/lib/executor.cjs +110 -3
- package/lib/install.cjs +13 -0
- package/lib/release.cjs +13 -1
- package/manifest.json +30 -0
- package/package.json +2 -2
- package/payload/.claude/hooks/emit-event.cjs +181 -0
- package/payload/.claude/hooks/prune.cjs +252 -0
- package/payload/.claude/hooks/recall-surface.cjs +465 -52
- package/payload/.claude/hooks/reward.cjs +244 -0
- package/payload/.claude/hooks/session-end-reward.cjs +265 -0
- package/payload/.claude/hooks/session-start.cjs +87 -2
- package/payload/.claude/hooks/sidecar.cjs +93 -0
- package/payload/.claude/hooks/synapse-engine.cjs +45 -3
- package/payload/.claude/hooks/wiki-lint.cjs +70 -1
- package/payload/.claude/settings.json +9 -2
- package/payload/.mcp.json +1 -1
- package/payload/.wrxn/dream.cjs +329 -5
- package/payload/.wrxn/events/.gitkeep +0 -0
- package/payload/.wrxn/harvest.cjs +159 -4
- package/payload/.wrxn/memory-synth.cjs +158 -31
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
};
|