@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 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
- module.exports = { shouldRelease, parseLog, decidePublish };
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.20.2",
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, timestamp, text)` — so it surfaces once. If the transcript dir is **missing or unreadable**, the search **degrades loudly to events-only** and says so in the output (it never crashes).
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. Scoped rows render as `this session`.
55
- - **`--since <when>`** — keep only hits at or after a timestamp floor. `<when>` is either `today` (from 00:00 **UTC** of the current day — record stamps are UTC) or an ISO-8601 date/datetime (e.g. `2026-06-26` or `2026-06-26T12:00:00Z`). An undatable hit is excluded.
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 active session.
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 active session
42
- // (opts.session), so a result set reads as scrollback relative to where the operator stands now.
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.session;
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. Byte-faithful copy of memory-synth.cjs (#62).
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
- const t = Date.parse(s);
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 Set(); // a prompt present in BOTH arms (same session+ts+text) must surface only once.
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
- const key = `${session}${ts}${text}`; // the AC dedup key NUL-joined so the parts can't bleed.
348
- if (seen.has(key)) return;
349
- seen.add(key);
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. A following token that is itself a --flag (or a missing value) is NOT
450
- // consumed as the value so `--session --regex` can't silently swallow --regex as the session id.
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
- return val == null || val.startsWith('--') ? undefined : val;
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
- const root = flag('root') || findInstallRoot();
480
- if (!root) {
481
- process.stderr.write('chat-search: cannot resolve the install root run inside a wrxn install or pass --root <dir>\n');
482
- process.exit(2);
483
- }
484
- // Wire the slice-3 flags (#85) into engine opts. The engine validates them (it throws a clean, user-facing
485
- // error on a bad regex / unparseable --since); the CLI catch below prints that one line and exits non-zero.
486
- const opts = {};
487
- const session = flag('session');
488
- if (session !== undefined) opts.session = session;
489
- const since = flag('since');
490
- if (since !== undefined) opts.since = since;
491
- if (hasFlag('regex')) opts.regex = true;
492
-
493
- let result;
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
- result = searchConversationalLog(terms.join(' '), opts, root);
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 the one clean line
499
- // the engine already built — never a Node stack or path — and exit non-zero (usage error).
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