@gcunharodrigues/wrxn 0.20.2 → 0.21.1
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 +34 -0
- package/lib/release-cut.cjs +160 -0
- package/lib/release.cjs +14 -1
- package/package.json +1 -1
- package/payload/.claude/skills/chat-search/SKILL.md +5 -5
- package/payload/.wrxn/chat-search.cjs +83 -32
package/bin/wrxn.cjs
CHANGED
|
@@ -15,6 +15,7 @@ const connect = require('../lib/connect.cjs');
|
|
|
15
15
|
const ship = require('../lib/ship.cjs');
|
|
16
16
|
const protect = require('../lib/protect.cjs');
|
|
17
17
|
const release = require('../lib/release.cjs');
|
|
18
|
+
const releaseCut = require('../lib/release-cut.cjs');
|
|
18
19
|
const brain = require('../lib/brain.cjs');
|
|
19
20
|
const statusline = require('../lib/statusline.cjs');
|
|
20
21
|
const { convert } = require('../lib/convert.cjs');
|
|
@@ -142,6 +143,16 @@ Usage:
|
|
|
142
143
|
running it (a non-destructive preview). Stops at the first failing
|
|
143
144
|
step (a failed push never opens a PR).
|
|
144
145
|
|
|
146
|
+
wrxn release [minor|patch|major] [--base <main>] [--root <dir>]
|
|
147
|
+
one-command deliberate release (ADR 0010): bump package.json+lock,
|
|
148
|
+
commit chore(release): <pkg> X.Y.Z on chore/release-X.Y.Z, then
|
|
149
|
+
delegate push → PR (base main) → arm auto-merge to the ship path. The
|
|
150
|
+
level is auto-computed from conventional commits since the last tag
|
|
151
|
+
(REUSE release-check); pass minor|patch|major to force it. Does NOT
|
|
152
|
+
publish or tag — CD publishes on merge. Repo-agnostic (cwd package.json
|
|
153
|
+
+ git). Refuses loud (no mutation) when it can't safely release: not on
|
|
154
|
+
main, dirty tree, behind origin/main, or nothing releasable + no level.
|
|
155
|
+
|
|
145
156
|
wrxn protect [--root <dir>] apply the wrxn-main-gate server-side ruleset to this repo's origin —
|
|
146
157
|
the hard gate that replaces the settings.local.json env-flag dance:
|
|
147
158
|
block direct push to the default branch, require a PR + the wrxn-ci
|
|
@@ -544,6 +555,29 @@ async function main(argv) {
|
|
|
544
555
|
return 2;
|
|
545
556
|
}
|
|
546
557
|
|
|
558
|
+
if (cmd === 'release') {
|
|
559
|
+
// One-command deliberate release (ADR 0010): compute the bump (explicit level, else auto from
|
|
560
|
+
// conventional commits since the last tag — REUSE release-check) → npm version → commit
|
|
561
|
+
// chore(release) on chore/release-X.Y.Z → DELEGATE push/PR/arm to the ship path. Does NOT publish
|
|
562
|
+
// or tag — CD owns that on merge. Repo-agnostic (operates on the cwd package.json + git). The git/
|
|
563
|
+
// npm/ship side effects run through release-cut's realDeps (real invocation). Refuses loud (exit 2,
|
|
564
|
+
// NO mutation) on: not on main / dirty tree / behind origin/main / nothing releasable + no level.
|
|
565
|
+
const root = path.resolve(args.flags.root || process.cwd());
|
|
566
|
+
const arg = args._[1]; // optional minor|patch|major
|
|
567
|
+
const base = args.flags.base || releaseCut.DEFAULT_BASE;
|
|
568
|
+
const res = releaseCut.cutRelease({ arg, base, root });
|
|
569
|
+
if (res.refused) {
|
|
570
|
+
process.stderr.write(`wrxn: refusing to release — ${res.reason} (nothing changed)\n`);
|
|
571
|
+
return 2;
|
|
572
|
+
}
|
|
573
|
+
if (!res.ok) {
|
|
574
|
+
process.stderr.write(`wrxn: release failed at "${res.step}"${res.detail ? ` — ${res.detail}` : ''}\n`);
|
|
575
|
+
return 2;
|
|
576
|
+
}
|
|
577
|
+
process.stdout.write(`released ${res.version} (${res.bump}, ${res.source}) — committed ${res.branch}, delegated to ship → PR on ${res.base} with auto-merge armed (CD publishes on merge)\n`);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
|
|
547
581
|
if (cmd === 'protect') {
|
|
548
582
|
// Apply the wrxn-main-gate server-side ruleset to this repo's origin — the hard gate that replaces
|
|
549
583
|
// the disarmable settings.local.json env-flag dance (ADR 0007). Repo-agnostic: the slug is read from
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// wrxn release — one-command deliberate release (PRD #101 / issue #102).
|
|
4
|
+
//
|
|
5
|
+
// Collapses the manual 4-step chore(release) dance into one command: compute the bump, apply
|
|
6
|
+
// `npm version <bump> --no-git-tag-version`, commit `chore(release): <pkg> X.Y.Z` on
|
|
7
|
+
// `chore/release-X.Y.Z`, then DELEGATE push → PR(base main) → arm-auto-merge to the existing
|
|
8
|
+
// `wrxn ship` path. It does NOT publish or tag — CD owns that on merge (ADR 0010).
|
|
9
|
+
//
|
|
10
|
+
// REUSE > CREATE: the bump is computed by lib/release.cjs's `shouldRelease` (the release-check
|
|
11
|
+
// type-gate; its previously-vestigial `bump` is now consumed); the push/PR/arm is the existing
|
|
12
|
+
// lib/ship.cjs `ship`. New code is only the thin orchestration + guards.
|
|
13
|
+
//
|
|
14
|
+
// Side effects go through an INJECTED `deps` boundary (readState / applyBump / commit / ship),
|
|
15
|
+
// defaulting to the real git/npm/gh impls — so the whole flow is unit-testable with NO live push.
|
|
16
|
+
// This mirrors lib/ship.cjs's injected invoker, finer-grained per the tdd skill's mocking.md
|
|
17
|
+
// ("SDK-style boundary functions over one generic fetcher": each boundary returns one shape).
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { execFileSync } = require('child_process');
|
|
22
|
+
const { shouldRelease, parseLog } = require('./release.cjs');
|
|
23
|
+
const ship = require('./ship.cjs');
|
|
24
|
+
|
|
25
|
+
const BUMPS = ['major', 'minor', 'patch'];
|
|
26
|
+
const TRUNK = 'main';
|
|
27
|
+
const DEFAULT_BASE = 'main';
|
|
28
|
+
|
|
29
|
+
function refusal(reason) {
|
|
30
|
+
return { ok: false, refused: true, reason };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decide the release bump. PURE. An explicit level wins; otherwise it is auto-computed from the
|
|
35
|
+
* conventional commits since the last tag (REUSE lib/release.cjs's release-check classifier — its
|
|
36
|
+
* once-vestigial `bump` output is now consumed). No level AND nothing releasable → refuse.
|
|
37
|
+
* @returns {{ bump:string, source:'explicit'|'auto' } | { refuse:true, reason:string }}
|
|
38
|
+
* @throws on an unknown explicit level (a malformed release is never runnable).
|
|
39
|
+
*/
|
|
40
|
+
function decideBump({ arg, commits, lastTag } = {}) {
|
|
41
|
+
if (arg != null && arg !== '') {
|
|
42
|
+
if (!BUMPS.includes(arg)) throw new Error(`unknown release level "${arg}" — expected ${BUMPS.join(' | ')}`);
|
|
43
|
+
return { bump: arg, source: 'explicit' };
|
|
44
|
+
}
|
|
45
|
+
const { release, bump } = shouldRelease(commits || []);
|
|
46
|
+
if (!release) return { refuse: true, reason: `nothing to release since ${lastTag || 'the last release'}` };
|
|
47
|
+
return { bump, source: 'auto' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The branch + PR title a release commits and ships, derived from the package name and the
|
|
52
|
+
* post-bump version. PURE. Version is passed through verbatim so prerelease tags (recon-wrxn's
|
|
53
|
+
* `6.0.0-wrxn.N`) survive — npm version is the authority on the new version, not this helper.
|
|
54
|
+
* @returns {{ branch:string, title:string }}
|
|
55
|
+
* @throws when name or version is missing.
|
|
56
|
+
*/
|
|
57
|
+
function releaseSpec({ name, version } = {}) {
|
|
58
|
+
if (!name || !version) throw new Error('releaseSpec requires a package name and version');
|
|
59
|
+
return {
|
|
60
|
+
branch: `chore/release-${version}`,
|
|
61
|
+
title: `chore(release): ${name} ${version}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cut a deliberate release. Guards (each refuses loud, non-zero, NO mutation) → bump decision →
|
|
67
|
+
* npm version → commit → delegate to ship. Side effects run through the injected `deps` boundary.
|
|
68
|
+
* @returns {{ ok:true, ... } | { ok:false, refused?:true, reason?:string, step?:string, detail?:string }}
|
|
69
|
+
*/
|
|
70
|
+
function cutRelease({ arg, base = DEFAULT_BASE, root, deps } = {}) {
|
|
71
|
+
const d = deps || realDeps(root || process.cwd());
|
|
72
|
+
const state = d.readState();
|
|
73
|
+
// ── guards ──
|
|
74
|
+
if (state.branch !== TRUNK) return refusal(`not on ${TRUNK} (on "${state.branch}") — release is cut from ${TRUNK}`);
|
|
75
|
+
if (state.dirty) return refusal('the working tree is dirty — commit or stash before releasing');
|
|
76
|
+
if (state.behind > 0) return refusal(`behind origin/${TRUNK} by ${state.behind} — pull before releasing`);
|
|
77
|
+
// ── bump decision (explicit arg wins; else auto from commits since the tag; else refuse) ──
|
|
78
|
+
let decision;
|
|
79
|
+
try {
|
|
80
|
+
decision = decideBump({ arg, commits: state.commits, lastTag: state.lastTag });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return refusal(err.message);
|
|
83
|
+
}
|
|
84
|
+
if (decision.refuse) return refusal(decision.reason);
|
|
85
|
+
// ── mutate: npm version (the version authority) → commit chore(release) → delegate to ship ──
|
|
86
|
+
const applied = d.applyBump(decision.bump);
|
|
87
|
+
if (!applied.ok) return { ok: false, step: 'npm-version', detail: applied.detail };
|
|
88
|
+
const spec = releaseSpec({ name: state.name, version: applied.newVersion });
|
|
89
|
+
const committed = d.commit({ branch: spec.branch, title: spec.title });
|
|
90
|
+
if (!committed.ok) return { ok: false, step: 'commit', detail: committed.detail };
|
|
91
|
+
const shipped = d.ship({ branch: spec.branch, title: spec.title, base });
|
|
92
|
+
if (!shipped.ok) return { ok: false, step: 'ship', failed: shipped.failed, detail: shipped.detail, branch: spec.branch };
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
bump: decision.bump,
|
|
96
|
+
source: decision.source,
|
|
97
|
+
version: applied.newVersion,
|
|
98
|
+
branch: spec.branch,
|
|
99
|
+
title: spec.title,
|
|
100
|
+
base,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The real side-effect boundary (the CLI layer wires this — what makes the release "validated by
|
|
106
|
+
* invocation"). Each method is one SDK-style operation over the cwd repo, repo-agnostic (reads the
|
|
107
|
+
* package.json + git in `root`, so the SAME command releases wrxn-kernel and recon-wrxn):
|
|
108
|
+
* readState — current branch, dirty, behind origin/main, last tag, commits since it, pkg name/version
|
|
109
|
+
* applyBump — `npm version <bump> --no-git-tag-version`; npm is the version authority (returns it)
|
|
110
|
+
* commit — branch chore/release-X.Y.Z, stage package.json (+ lockfile if present), commit
|
|
111
|
+
* ship — REUSE lib/ship.cjs (push → PR base main → arm auto-merge); NOT reimplemented here
|
|
112
|
+
*/
|
|
113
|
+
function realDeps(root) {
|
|
114
|
+
const git = (...a) => execFileSync('git', ['-C', root, ...a], { encoding: 'utf8' }).trim();
|
|
115
|
+
return {
|
|
116
|
+
readState() {
|
|
117
|
+
let branch = '';
|
|
118
|
+
try { branch = git('branch', '--show-current'); } catch { branch = ''; }
|
|
119
|
+
let dirty = false;
|
|
120
|
+
try { dirty = git('status', '--porcelain') !== ''; } catch { dirty = false; }
|
|
121
|
+
// Best-effort fetch so "behind" reflects the real remote; never block on a fetch failure
|
|
122
|
+
// (offline / no remote) — the server-side require-up-to-date ruleset is the hard gate.
|
|
123
|
+
try { execFileSync('git', ['-C', root, 'fetch', 'origin', TRUNK, '--quiet'], { stdio: 'ignore' }); } catch { /* offline / no remote */ }
|
|
124
|
+
let behind = 0;
|
|
125
|
+
try { behind = parseInt(git('rev-list', '--count', `HEAD..origin/${TRUNK}`), 10) || 0; } catch { behind = 0; }
|
|
126
|
+
let lastTag = '';
|
|
127
|
+
try { lastTag = git('describe', '--tags', '--abbrev=0'); } catch { lastTag = ''; }
|
|
128
|
+
let commits = [];
|
|
129
|
+
try { commits = parseLog(git('log', '--format=%B%x00', lastTag ? `${lastTag}..HEAD` : 'HEAD')); } catch { commits = []; }
|
|
130
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
131
|
+
return { branch, dirty, behind, lastTag, commits, name: pkg.name, version: pkg.version };
|
|
132
|
+
},
|
|
133
|
+
applyBump(bump) {
|
|
134
|
+
try {
|
|
135
|
+
execFileSync('npm', ['version', bump, '--no-git-tag-version'], { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
136
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
137
|
+
return { ok: true, newVersion: pkg.version };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, detail: `npm version ${bump} failed: ${err.message}` };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
commit({ branch, title }) {
|
|
143
|
+
try {
|
|
144
|
+
git('checkout', '-b', branch);
|
|
145
|
+
const files = ['package.json'];
|
|
146
|
+
if (fs.existsSync(path.join(root, 'package-lock.json'))) files.push('package-lock.json');
|
|
147
|
+
git('add', '--', ...files);
|
|
148
|
+
git('commit', '-m', title);
|
|
149
|
+
return { ok: true };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return { ok: false, detail: `commit failed: ${err.message}` };
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
ship({ branch, title, base }) {
|
|
155
|
+
return ship.ship({ branch, title, base }); // real defaultInvoke (git push + gh)
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { decideBump, releaseSpec, cutRelease, realDeps, BUMPS, DEFAULT_BASE };
|
package/lib/release.cjs
CHANGED
|
@@ -60,4 +60,17 @@ function decidePublish({ typeRelease, versionOnNpm } = {}) {
|
|
|
60
60
|
return Boolean(typeRelease) || !versionOnNpm;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* decideWarn({ typeRelease, versionOnNpm }) → boolean. The silent-skip detector (#103): warn when the
|
|
65
|
+
* merged commits warrant a type release AND package.json.version is already on npm. In that case
|
|
66
|
+
* decidePublish is true (typeRelease), so the run proceeds — but the publish step's idempotency guard
|
|
67
|
+
* skips the already-published version, so nothing reaches npm: the releasable commits silently never
|
|
68
|
+
* ship. The workflow turns this into a visible `::warning::` to run `wrxn release` (bump + publish).
|
|
69
|
+
* Pure — the npm probe stays in the workflow. False for a normal publish (versionOnNpm false) and for
|
|
70
|
+
* non-release merges (the idempotent no-op is expected, not a mistake).
|
|
71
|
+
*/
|
|
72
|
+
function decideWarn({ typeRelease, versionOnNpm } = {}) {
|
|
73
|
+
return Boolean(typeRelease) && Boolean(versionOnNpm);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { shouldRelease, parseLog, decidePublish, decideWarn };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
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"
|
|
@@ -17,7 +17,7 @@ user-invocable: true
|
|
|
17
17
|
- **Event log** — `.wrxn/events/*.jsonl`, the per-session, secret-redacted **user prompts** emit-event.cjs appends.
|
|
18
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
19
|
|
|
20
|
-
A prompt that appears in **both** arms is de-duplicated — by `(session,
|
|
20
|
+
A prompt that appears in **both** arms is de-duplicated — by `(session, whitespace-normalized text, timestamp within a tight window)` — so a single turn surfaces once even when its two arms stamp it ms apart or differ by whitespace. If the transcript dir is **missing, unreadable, or wholesale-drifted** (present but every line an unknown type, so it yields no usable turn), the search **degrades loudly to events-only** and says so in the output (it never crashes).
|
|
21
21
|
|
|
22
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
23
|
|
|
@@ -51,8 +51,8 @@ When the operator references an earlier moment ("like we discussed", "the decisi
|
|
|
51
51
|
|
|
52
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
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.
|
|
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.
|
|
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. Scoping only *narrows* the rows — it does **not** relabel them: the `this session` label tracks the genuinely-live session (`CLAUDE_SESSION_ID`), so scoping to a **past** session shows its real id, not `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`). A datetime **without an explicit zone** is read as **UTC** (matching the record stamps), not machine-local time. An undatable hit is excluded.
|
|
56
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
57
|
|
|
58
58
|
```bash
|
|
@@ -71,9 +71,9 @@ Hits are **most-recent-first**, one per line:
|
|
|
71
71
|
```
|
|
72
72
|
|
|
73
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
|
|
74
|
+
- The session column collapses to `this session` for hits from the genuinely-live session (`CLAUDE_SESSION_ID`), independent of any `--session` scope.
|
|
75
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).
|
|
76
|
+
- If the transcript arm is unavailable (missing, unreadable, or wholesale-drifted), a trailing `chat-search: transcript arm unavailable — showing event-log results only.` line is appended (loud degrade).
|
|
77
77
|
|
|
78
78
|
## Boundaries
|
|
79
79
|
|
|
@@ -38,10 +38,12 @@ function snippetFor(text, matchesLine) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
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
|
|
42
|
-
// (opts.
|
|
41
|
+
// The session column collapses to "this session" when the hit is from the caller's genuinely-LIVE session
|
|
42
|
+
// (opts.activeSession — the CLI wires it from CLAUDE_SESSION_ID), so a result set reads as scrollback relative
|
|
43
|
+
// to where the operator stands now. This is DECOUPLED from the --session SCOPE filter (opts.session): scoping
|
|
44
|
+
// to a PAST session narrows the rows but never relabels them "this session" (#98).
|
|
43
45
|
function renderHit(hit, opts) {
|
|
44
|
-
const active = opts && opts.
|
|
46
|
+
const active = opts && opts.activeSession;
|
|
45
47
|
const sessionLabel = active && hit.session === active ? 'this session' : hit.session;
|
|
46
48
|
return `${hit.ts} · ${sessionLabel} · ${hit.role} · ${hit.snippet}`;
|
|
47
49
|
}
|
|
@@ -89,7 +91,8 @@ const INJECTED_STRIP_MAX = 65536;
|
|
|
89
91
|
// Strip hook-injected framework-context blocks from one text part BEFORE matching. Two phases: (1) every
|
|
90
92
|
// well-delimited <tag>…</tag> block anywhere; (2) a part-LEADING unclosed <tag>… (transcript truncated
|
|
91
93
|
// 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.
|
|
94
|
+
// FAIL-OPEN: any fault returns the input unchanged. Logic-identical to memory-synth.cjs's stripInjectedContext
|
|
95
|
+
// (#62) — same strip semantics, not a byte-for-byte copy (and the 64KB strip cap is the inherited #62 tradeoff).
|
|
93
96
|
function stripInjectedContext(text) {
|
|
94
97
|
try {
|
|
95
98
|
let out = String(text || '');
|
|
@@ -223,7 +226,12 @@ function parseSince(raw) {
|
|
|
223
226
|
d.setUTCHours(0, 0, 0, 0);
|
|
224
227
|
return d.getTime();
|
|
225
228
|
}
|
|
226
|
-
|
|
229
|
+
// N3 (#99): record stamps are UTC, but Date.parse reads an ISO datetime WITH a time component and NO zone
|
|
230
|
+
// designator ("2026-06-26T12:00:00") as MACHINE-LOCAL time — silently shifting the floor by the host offset.
|
|
231
|
+
// Normalize it to UTC by appending Z. A date-only form (no 'T') is already UTC under Date.parse, and an
|
|
232
|
+
// explicit zone (trailing Z or a ±HH[:MM] offset) is honored as written — both pass through untouched.
|
|
233
|
+
const normalized = /[tT]/.test(s) && !/[zZ]$/.test(s) && !/[+-]\d{2}(:?\d{2})?$/.test(s) ? `${s}Z` : s;
|
|
234
|
+
const t = Date.parse(normalized);
|
|
227
235
|
if (Number.isNaN(t)) {
|
|
228
236
|
throw inputError(`chat-search: --since value "${raw}" is not a date — use "today" or an ISO-8601 date (e.g. 2026-06-26)`);
|
|
229
237
|
}
|
|
@@ -314,6 +322,23 @@ function compileUserRegex(pattern) {
|
|
|
314
322
|
}
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
// ── cross-arm dedup identity (#97) ─────────────────────────────────────────────
|
|
326
|
+
// The SAME prompt reaches both arms stamped by DIFFERENT processes: emit-event.cjs writes the event ts at
|
|
327
|
+
// hook-fire, the harness writes the transcript timestamp when it persists the turn — they differ by ms — and
|
|
328
|
+
// the transcript text can differ from the event text by whitespace (hygiene strip, soft-wrap). So the dedup
|
|
329
|
+
// identity is (session, whitespace-normalized text, ts within a TIGHT ±window), NOT an exact triple. The
|
|
330
|
+
// window is 2s: a single turn's two stamps land within ~1s in practice, while two genuinely-distinct prompts
|
|
331
|
+
// are seconds-to-minutes apart AND (almost always) differ in text — so the window only ever collapses what is
|
|
332
|
+
// really one turn. The normalized text is the primary discriminator; the window only absorbs the writer
|
|
333
|
+
// clock-skew on an otherwise-identical turn (two distinct prompts inside the window keep their distinct text,
|
|
334
|
+
// so they never merge; the same text outside the window stays two distinct moments).
|
|
335
|
+
const DEDUP_WINDOW_MS = 2000;
|
|
336
|
+
// Normalize a message's text for the dedup key: trim the ends and collapse every interior whitespace run
|
|
337
|
+
// (spaces, tabs, newlines) to a single space, so an event/transcript pair that differs only in spacing keys alike.
|
|
338
|
+
function normalizeForDedup(text) {
|
|
339
|
+
return String(text).trim().replace(/\s+/g, ' ');
|
|
340
|
+
}
|
|
341
|
+
|
|
317
342
|
// ── the engine seam ───────────────────────────────────────────────────────────
|
|
318
343
|
// searchConversationalLog(query, opts, roots): scan BOTH arms across the given roots' sessions and return
|
|
319
344
|
// the turns that match `query` — a case-insensitive substring by default, or (opts.regex) the compiled
|
|
@@ -325,7 +350,7 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
325
350
|
const q = String(query == null ? '' : query);
|
|
326
351
|
const needle = q.toLowerCase();
|
|
327
352
|
const hits = [];
|
|
328
|
-
const seen = new
|
|
353
|
+
const seen = new Map(); // dedup index (#97): `${session}${normText}` → [{ tsMs, ts }] already surfaced; a near-stamp match collapses cross-arm duplicates.
|
|
329
354
|
let degraded = false; // set when the transcript arm could not be consulted → loud events-only degrade (#84).
|
|
330
355
|
|
|
331
356
|
// ── slice-3 filters (#85): each is an opts field applied per-record inside consider(), so it composes
|
|
@@ -344,9 +369,21 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
344
369
|
if (sessionScope != null && session !== sessionScope) return; // --session: exact-match a single session
|
|
345
370
|
if (typeof text !== 'string' || !lineMatches(text)) return; // --regex pattern, else case-insensitive substring
|
|
346
371
|
if (sinceThreshold != null && !(Date.parse(ts) >= sinceThreshold)) return; // --since: drop hits before the floor (an undatable ts → NaN → dropped)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
372
|
+
// Cross-arm dedup (#97): same session + whitespace-normalized text + a ts within the tight window = the
|
|
373
|
+
// same turn → surface once. NUL-joined so session and text can't bleed. An undatable or exactly-equal
|
|
374
|
+
// stamp falls back to ts-string equality, so an unparseable ts still collapses its exact twin.
|
|
375
|
+
const key = `${session}${normalizeForDedup(text)}`;
|
|
376
|
+
const tsMs = Date.parse(ts);
|
|
377
|
+
const prior = seen.get(key);
|
|
378
|
+
if (prior) {
|
|
379
|
+
const dup = prior.some(
|
|
380
|
+
(e) => (Number.isFinite(tsMs) && Number.isFinite(e.tsMs) && Math.abs(e.tsMs - tsMs) <= DEDUP_WINDOW_MS) || e.ts === ts,
|
|
381
|
+
);
|
|
382
|
+
if (dup) return; // a near-identical turn from the other arm (or an exact twin) already surfaced
|
|
383
|
+
prior.push({ tsMs, ts });
|
|
384
|
+
} else {
|
|
385
|
+
seen.set(key, [{ tsMs, ts }]);
|
|
386
|
+
}
|
|
350
387
|
hits.push({ ts, session, role, snippet: snippetFor(text, lineMatches) });
|
|
351
388
|
}
|
|
352
389
|
|
|
@@ -388,6 +425,7 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
388
425
|
if (!tfiles) {
|
|
389
426
|
degraded = true;
|
|
390
427
|
} else {
|
|
428
|
+
let recognizedTurns = 0; // user/assistant turns this dir yielded; zero across a non-empty dir = wholesale drift (#99 F3)
|
|
391
429
|
for (const file of tfiles) {
|
|
392
430
|
let lines;
|
|
393
431
|
try {
|
|
@@ -404,12 +442,17 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
404
442
|
continue; // skip a malformed transcript line, never crash the scan
|
|
405
443
|
}
|
|
406
444
|
if (!rec || (rec.type !== 'user' && rec.type !== 'assistant')) continue; // only user/assistant turns; unknown line types (summary/system/…) skipped
|
|
445
|
+
recognizedTurns++; // a recognized turn (matchable or not) → this dir is NOT wholesale-drifted (#99 F3)
|
|
407
446
|
// hygiene pipeline: flatten → strip injected framework context (a block holding the term is not a
|
|
408
447
|
// hit) → redact secrets (raw chat can echo a credential; scrub BEFORE it can surface in a snippet).
|
|
409
448
|
const text = redactSecrets(stripInjectedContext(transcriptText(rec)));
|
|
410
449
|
consider(rec.timestamp, rec.sessionId, rec.type, text);
|
|
411
450
|
}
|
|
412
451
|
}
|
|
452
|
+
// F3 (#99): a present, readable dir whose EVERY line is an unknown type yielded zero usable turns — the
|
|
453
|
+
// arm is effectively unavailable → loud events-only degrade. Per-RECORD drift (some good turns) stays
|
|
454
|
+
// silent, and a present-but-empty dir ([]) is reachable-but-nothing and stays silent, both as before.
|
|
455
|
+
if (tfiles.length > 0 && recognizedTurns === 0) degraded = true;
|
|
413
456
|
}
|
|
414
457
|
}
|
|
415
458
|
|
|
@@ -446,13 +489,16 @@ function findInstallRoot(start) {
|
|
|
446
489
|
return null;
|
|
447
490
|
}
|
|
448
491
|
|
|
449
|
-
// Value of a --name <value> flag
|
|
450
|
-
//
|
|
492
|
+
// Value of a --name <value> flag, or undefined when the flag is ABSENT (optional). A PRESENT flag whose value
|
|
493
|
+
// is missing or is itself a --flag fails LOUD (N2, #99) — parity with `--session ""`, never a silent
|
|
494
|
+
// scope-widening drop: so `--session --regex` can't swallow --regex as the id and a trailing `--since` can't
|
|
495
|
+
// vanish. The throw is userFacing, so main()'s catch prints one clean line and exits non-zero.
|
|
451
496
|
function flag(name) {
|
|
452
497
|
const i = process.argv.indexOf(`--${name}`);
|
|
453
498
|
if (i < 0) return undefined;
|
|
454
499
|
const val = process.argv[i + 1];
|
|
455
|
-
|
|
500
|
+
if (val == null || val.startsWith('--')) throw inputError(`chat-search: --${name} requires a value`);
|
|
501
|
+
return val;
|
|
456
502
|
}
|
|
457
503
|
|
|
458
504
|
// Presence of a boolean --name flag (e.g. --regex), which carries no value.
|
|
@@ -476,34 +522,39 @@ function main() {
|
|
|
476
522
|
process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]\n');
|
|
477
523
|
process.exit(2);
|
|
478
524
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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;
|
|
525
|
+
// Flag parsing AND the engine call live inside ONE user-facing try: a value-flag with a missing/--prefixed
|
|
526
|
+
// value (flag(), N2 #99) and a bad regex / unparseable --since (the engine) both throw a clean userFacing
|
|
527
|
+
// error → the catch prints that one line and exits non-zero (never a Node stack or absolute path).
|
|
494
528
|
try {
|
|
495
|
-
|
|
529
|
+
const root = flag('root') || findInstallRoot();
|
|
530
|
+
if (!root) {
|
|
531
|
+
process.stderr.write('chat-search: cannot resolve the install root — run inside a wrxn install or pass --root <dir>\n');
|
|
532
|
+
process.exit(2);
|
|
533
|
+
}
|
|
534
|
+
// Wire the slice-3 flags (#85) into engine opts.
|
|
535
|
+
const opts = {};
|
|
536
|
+
const session = flag('session');
|
|
537
|
+
if (session !== undefined) opts.session = session;
|
|
538
|
+
const since = flag('since');
|
|
539
|
+
if (since !== undefined) opts.since = since;
|
|
540
|
+
if (hasFlag('regex')) opts.regex = true;
|
|
541
|
+
// The "this session" label tracks the genuinely-LIVE session (Claude Code exports CLAUDE_SESSION_ID), NOT
|
|
542
|
+
// the --session SCOPE filter — so scoping to a PAST session never relabels its rows "this session" (#98).
|
|
543
|
+
const activeSession = process.env.CLAUDE_SESSION_ID;
|
|
544
|
+
if (activeSession) opts.activeSession = activeSession;
|
|
545
|
+
|
|
546
|
+
const result = searchConversationalLog(terms.join(' '), opts, root);
|
|
547
|
+
process.stdout.write(result.rendered + '\n');
|
|
548
|
+
process.exit(0);
|
|
496
549
|
} catch (err) {
|
|
497
550
|
if (err && err.userFacing) {
|
|
498
|
-
// invalid flag input (bad/catastrophic regex, unparseable --since): fail LOUD with
|
|
499
|
-
// the
|
|
551
|
+
// invalid flag input (missing flag value, bad/catastrophic regex, unparseable --since): fail LOUD with
|
|
552
|
+
// the one clean line — never a Node stack or path — and exit non-zero (usage error).
|
|
500
553
|
process.stderr.write(err.message + '\n');
|
|
501
554
|
process.exit(2);
|
|
502
555
|
}
|
|
503
556
|
throw err; // an unexpected fault → the entrypoint wrap turns it into a clean path-free diagnostic
|
|
504
557
|
}
|
|
505
|
-
process.stdout.write(result.rendered + '\n');
|
|
506
|
-
process.exit(0);
|
|
507
558
|
}
|
|
508
559
|
|
|
509
560
|
// Belt-and-suspenders fail-loud (mirrors emit-event.cjs's entrypoint wrap): the per-file read and root
|