@gcunharodrigues/wrxn 0.18.2 → 0.19.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 +10 -0
- package/package.json +1 -1
- package/payload/.claude/hooks/enforce-pipeline-adherence.cjs +154 -3
- package/payload/.claude/hooks/wiki-lint.cjs +5 -1
- package/payload/.claude/settings.json +2 -1
- package/payload/.claude/skills/chat-search/SKILL.md +58 -0
- package/payload/.claude/skills/compass/SKILL.md +2 -2
- package/payload/.wrxn/chat-search.cjs +173 -0
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.
|
|
3
|
+
"version": "0.19.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
|
-
//
|
|
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 };
|
|
@@ -95,7 +95,11 @@ function wikilinkTargets(body) {
|
|
|
95
95
|
const out = new Set();
|
|
96
96
|
const re = /\[\[([^\]\[]+)\]\]/g;
|
|
97
97
|
let m;
|
|
98
|
-
|
|
98
|
+
// Strip fenced regions, THEN inline code spans (`…`): a [[slug]] in either is illustrative example syntax,
|
|
99
|
+
// not a navigable link, so it must not be checked for a dead target (#28). The inline pass uses disjoint
|
|
100
|
+
// adjacent classes (`+ / [^`\n] / `+) so it stays linear — no backtracking, per this file's ReDoS discipline;
|
|
101
|
+
// `\n` in the class keeps a span single-line so a stray backtick can't swallow real prose links below it.
|
|
102
|
+
const scannable = stripFencedCode(body).replace(/`+[^`\n]*`+/g, '');
|
|
99
103
|
while ((m = re.exec(scannable))) {
|
|
100
104
|
const slug = m[1].split('|')[0].split('#')[0].trim();
|
|
101
105
|
if (slug) out.add(slug);
|
|
@@ -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,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chat-search
|
|
3
|
+
description: On-demand retrieval over the Conversational log — grep an exact past moment (timestamp · session · role · snippet) from this project's event log. 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 (slice 1 — event-log arm)
|
|
14
|
+
|
|
15
|
+
This slice reads **one arm** of the Conversational log: the **event log** at `.wrxn/events/*.jsonl` — the per-session, secret-redacted **user prompts** emit-event.cjs appends. The harness-transcript arm (the only source of **assistant** turns and full message content) and the `--session` / `--since` / `--regex` flags are follow-on slices; this slice establishes the engine seam.
|
|
16
|
+
|
|
17
|
+
## Invocation
|
|
18
|
+
|
|
19
|
+
### Operator
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/chat-search baton echo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Engine (what the skill runs)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
node .wrxn/chat-search.cjs <search-term...> [--root <dir>]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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). It prints the rendered result and exits 0 — a nothing-found result is a normal outcome, never an error exit.
|
|
32
|
+
|
|
33
|
+
### Agent (self-invocation)
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
## Output
|
|
38
|
+
|
|
39
|
+
Hits are **most-recent-first**, one per line:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
<timestamp> · <session (or "this session")> · <role> · <snippet (±1 line context)>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `role` is `user` for a prompt record.
|
|
46
|
+
- The session column collapses to `this session` for hits from the active session.
|
|
47
|
+
- No match → an explicit `chat-search: nothing found for "<term>" ...` line (never silence, never a crash).
|
|
48
|
+
|
|
49
|
+
## Boundaries
|
|
50
|
+
|
|
51
|
+
- **Pure in-process grep.** No Brain, no embeddings, no recon/serve/daemon, no network (ADR 0008).
|
|
52
|
+
- **Read-only.** It never writes a wiki page, never mutates the event log.
|
|
53
|
+
- **Never an automatic hook.** Invoked only by the operator or the agent, deliberately (ADR 0002).
|
|
54
|
+
- Default scope is **this project's sessions** — every `.jsonl` under `.wrxn/events/`. Cross-project search is out of scope.
|
|
55
|
+
|
|
56
|
+
## Source
|
|
57
|
+
|
|
58
|
+
WRXN Kernel issue #83 (slice 1 of 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,173 @@
|
|
|
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).
|
|
5
|
+
// Self-contained: this ships INTO an install and MUST NOT import the kernel lib (node stdlib only).
|
|
6
|
+
//
|
|
7
|
+
// This slice scans the EVENT-LOG arm only — <root>/.wrxn/events/*.jsonl, the per-session, pre-redacted
|
|
8
|
+
// user-prompt records emit-event.cjs appends. (The harness-transcript arm + flags are #84/#85.) Pure
|
|
9
|
+
// in-process fs scan: no Brain, no embeddings, no recon/serve dependency (ADR 0008). Never wired as a
|
|
10
|
+
// per-prompt hook — it is invoked deliberately, by the operator or the agent (ADR 0002 boundary).
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Only prompt records carry text in the event arm, so only they can match. role is derived from kind:
|
|
16
|
+
// a user prompt is kind 'prompt' → role 'user'.
|
|
17
|
+
function roleFromKind(kind) {
|
|
18
|
+
return kind === 'prompt' ? 'user' : String(kind || '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A snippet is the matching line plus ±1 line of context within the message text (`needle` is already
|
|
22
|
+
// lowercased). A single-line prompt collapses to just that line. A match that only spans a line boundary
|
|
23
|
+
// has no single matching line → fall back to the trimmed whole text (defensive; rare).
|
|
24
|
+
function snippetFor(text, needle) {
|
|
25
|
+
const lines = String(text).split('\n');
|
|
26
|
+
const i = lines.findIndex((l) => l.toLowerCase().includes(needle));
|
|
27
|
+
if (i === -1) return String(text).trim();
|
|
28
|
+
const from = Math.max(0, i - 1);
|
|
29
|
+
const to = Math.min(lines.length - 1, i + 1);
|
|
30
|
+
return lines.slice(from, to + 1).join('\n').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── render: one hit → "timestamp · session (or 'this session') · role · snippet" ──
|
|
34
|
+
// The session column collapses to "this session" when the hit is from the caller's active session
|
|
35
|
+
// (opts.session), so a result set reads as scrollback relative to where the operator stands now.
|
|
36
|
+
function renderHit(hit, opts) {
|
|
37
|
+
const active = opts && opts.session;
|
|
38
|
+
const sessionLabel = active && hit.session === active ? 'this session' : hit.session;
|
|
39
|
+
return `${hit.ts} · ${sessionLabel} · ${hit.role} · ${hit.snippet}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── locations (injectable — tests pass a temp root) ───────────────────────────
|
|
43
|
+
// The events dir for an install root, mirroring emit-event.cjs's eventsDir(root).
|
|
44
|
+
function eventsDirOf(root) {
|
|
45
|
+
return path.join(root, '.wrxn', 'events');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Normalize the roots arg to a list of install roots. A string is one root; an array is many. The seam
|
|
49
|
+
// stays pure/injectable — it never resolves cwd itself; the CLI passes the resolved root in.
|
|
50
|
+
function normalizeRoots(roots) {
|
|
51
|
+
if (!roots) return [];
|
|
52
|
+
return Array.isArray(roots) ? roots : [roots];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every <sid>.jsonl session log in an events dir. Absent dir → [] (no crash), mirroring wiki.cjs.
|
|
56
|
+
function listEventFiles(dir) {
|
|
57
|
+
try {
|
|
58
|
+
return fs
|
|
59
|
+
.readdirSync(dir)
|
|
60
|
+
.filter((n) => n.endsWith('.jsonl'))
|
|
61
|
+
.map((n) => path.join(dir, n));
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── the engine seam ───────────────────────────────────────────────────────────
|
|
68
|
+
// searchConversationalLog(query, opts, roots): scan the event-log arm across the given roots' sessions
|
|
69
|
+
// and return the prompts whose text contains `query` (case-insensitive substring). Each hit is a
|
|
70
|
+
// structured record { ts, session, role, snippet }.
|
|
71
|
+
function searchConversationalLog(query, opts, roots) {
|
|
72
|
+
const q = String(query == null ? '' : query);
|
|
73
|
+
const needle = q.toLowerCase();
|
|
74
|
+
const hits = [];
|
|
75
|
+
|
|
76
|
+
for (const root of normalizeRoots(roots)) {
|
|
77
|
+
for (const file of listEventFiles(eventsDirOf(root))) {
|
|
78
|
+
let lines;
|
|
79
|
+
try {
|
|
80
|
+
lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
81
|
+
} catch {
|
|
82
|
+
continue; // an unreadable entry (EISDIR dir, EACCES, ENOENT/TOCTOU, broken symlink) → skip it, keep scanning
|
|
83
|
+
}
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (!line.trim()) continue;
|
|
86
|
+
let rec;
|
|
87
|
+
try {
|
|
88
|
+
rec = JSON.parse(line);
|
|
89
|
+
} catch {
|
|
90
|
+
continue; // skip a malformed line, never crash the scan
|
|
91
|
+
}
|
|
92
|
+
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)
|
|
93
|
+
if (!rec.text.toLowerCase().includes(needle)) continue;
|
|
94
|
+
hits.push({ ts: rec.ts, session: rec.sid, role: roleFromKind(rec.kind), snippet: snippetFor(rec.text, needle) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Recency-first: newest timestamp on top. ISO-8601 stamps parse cleanly; an unparseable ts sorts last.
|
|
100
|
+
hits.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
|
|
101
|
+
|
|
102
|
+
const found = hits.length > 0;
|
|
103
|
+
const rendered = found
|
|
104
|
+
? hits.map((h) => renderHit(h, opts)).join('\n')
|
|
105
|
+
: `chat-search: nothing found for "${q}" in the conversational log (event log).`;
|
|
106
|
+
|
|
107
|
+
return { query: q, total: hits.length, found, hits, rendered };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── CLI: node .wrxn/chat-search.cjs <term...> [--root <dir>] ──────────────────
|
|
111
|
+
// The operator-invocable surface (/chat-search <term>). Resolves the install root by walking up to the
|
|
112
|
+
// wrxn.install.json receipt (mirrors wiki.cjs), or honors a --root override (tests). Prints the rendered
|
|
113
|
+
// result and exits 0 — a nothing-found result is a normal outcome, never an error exit.
|
|
114
|
+
|
|
115
|
+
// Mirrors wiki.cjs / emit-event.cjs findInstallRoot (no kernel-lib import in a shipped file).
|
|
116
|
+
function findInstallRoot(start) {
|
|
117
|
+
let dir = start || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
118
|
+
for (let i = 0; i < 12; i++) {
|
|
119
|
+
if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
|
|
120
|
+
const up = path.dirname(dir);
|
|
121
|
+
if (up === dir) break;
|
|
122
|
+
dir = up;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flag(name) {
|
|
128
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
129
|
+
return i > -1 ? process.argv[i + 1] : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Positional search terms: argv from index 2 up to the first --flag.
|
|
133
|
+
function positionals() {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
136
|
+
if (process.argv[i].startsWith('--')) break;
|
|
137
|
+
out.push(process.argv[i]);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function main() {
|
|
143
|
+
const terms = positionals();
|
|
144
|
+
if (terms.length === 0) {
|
|
145
|
+
process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>]\n');
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
const root = flag('root') || findInstallRoot();
|
|
149
|
+
if (!root) {
|
|
150
|
+
process.stderr.write('chat-search: cannot resolve the install root — run inside a wrxn install or pass --root <dir>\n');
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
153
|
+
// opts is empty here: the live current-session id (which the renderer collapses to "this session") has
|
|
154
|
+
// no CLI source yet — it arrives with the transcript arm (#84). Not wired in this slice.
|
|
155
|
+
const result = searchConversationalLog(terms.join(' '), {}, root);
|
|
156
|
+
process.stdout.write(result.rendered + '\n');
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Belt-and-suspenders fail-loud (mirrors emit-event.cjs's entrypoint wrap): the per-file read and root
|
|
161
|
+
// resolution are already guarded, so main() should not throw — but if any residual fault escapes, exit
|
|
162
|
+
// with a clean ONE-LINE diagnostic (a path-free error code only — never a Node stack or absolute path).
|
|
163
|
+
if (require.main === module) {
|
|
164
|
+
try {
|
|
165
|
+
main();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const code = err && err.code ? ` (${err.code})` : '';
|
|
168
|
+
process.stderr.write(`chat-search: search failed unexpectedly${code}\n`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { searchConversationalLog, renderHit };
|