@gcunharodrigues/wrxn 0.21.0 → 0.22.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 CHANGED
@@ -558,6 +558,11 @@
558
558
  "class": "managed",
559
559
  "profile": "project"
560
560
  },
561
+ {
562
+ "path": ".wrxn/tracker-target.cjs",
563
+ "class": "managed",
564
+ "profile": "project"
565
+ },
561
566
  {
562
567
  "path": ".wrxn/wiki.cjs",
563
568
  "class": "managed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.21.0",
3
+ "version": "0.22.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"
@@ -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
 
@@ -83,3 +83,34 @@ Or "None - can start immediately" if no blockers.
83
83
  </issue-template>
84
84
 
85
85
  Do NOT close or modify any parent issue.
86
+
87
+ ## `--repo` / cross-repo targeting
88
+
89
+ By default this skill publishes the slices to the project's configured tracker (see
90
+ `docs/agents/issue-tracker.md`). Passing **`--repo owner/repo`** instead targets a named **GitHub** repo
91
+ for this one invocation (e.g. the kernel or `recon-wrxn`), so you can slice a sibling repo from this
92
+ session without leaving the pipeline. Resolve the target through the ONE shared helper —
93
+ `.wrxn/tracker-target.cjs` — never hand-roll the parsing:
94
+
95
+ ```bash
96
+ # WITH --repo present: pass the owner/repo value as the trailing argument.
97
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))' "owner/repo"
98
+ # WITHOUT --repo: OMIT the argument entirely (process.argv[1] is undefined → resolves to local).
99
+ # NEVER pass "" — resolveTarget("") THROWS (empty is malformed, not "absent"), which would break the local path.
100
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))'
101
+ ```
102
+
103
+ It returns `{ mechanism, repo, ghBaseArgs }` and **throws loud** on a malformed / empty / trailing
104
+ `--repo` BEFORE any publish — let that refusal surface; never proceed on a bad target.
105
+
106
+ - **`mechanism: "local"`** (no `--repo`) → publish exactly as today, to `.scratch/` — unchanged.
107
+ - **`mechanism: "github"`** → publish via `gh`, prepending the returned **`ghBaseArgs`** (`-R owner/repo`)
108
+ to every `gh` call, e.g. `gh issue create -R owner/repo --title "…" --body "…" --label ready-for-agent`.
109
+ Slices are AFK-ready, so apply `ready-for-agent` from the shared wrxn triage vocab
110
+ (`ready-for-agent` / `backlog` / `epic`). `gh` fails loud if the target lacks the label; do not work
111
+ around it. The helper's `publishIssue({ target, title, body, label }, gh)` builds that exact argv and
112
+ refuses an off-vocab label — use it as the create boundary.
113
+
114
+ **Target the SAME `owner/repo` the PRD was published to** (via `to-prd --repo`), so each slice's "Parent"
115
+ references a real PRD issue number on that tracker. Publish slices in dependency order so the "Blocked by"
116
+ field can cite real issue identifiers.
@@ -72,3 +72,34 @@ A description of the things that are out of scope for this PRD.
72
72
  Any further notes about the feature.
73
73
 
74
74
  </prd-template>
75
+
76
+ ## `--repo` / cross-repo targeting
77
+
78
+ By default this skill publishes the PRD to the project's configured tracker (see
79
+ `docs/agents/issue-tracker.md`). Passing **`--repo owner/repo`** instead targets a named **GitHub** repo
80
+ for this one invocation (e.g. the kernel or `recon-wrxn`), so you can spec a sibling repo from this
81
+ session without leaving the pipeline. Resolve the target through the ONE shared helper —
82
+ `.wrxn/tracker-target.cjs` — never hand-roll the parsing:
83
+
84
+ ```bash
85
+ # WITH --repo present: pass the owner/repo value as the trailing argument.
86
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))' "owner/repo"
87
+ # WITHOUT --repo: OMIT the argument entirely (process.argv[1] is undefined → resolves to local).
88
+ # NEVER pass "" — resolveTarget("") THROWS (empty is malformed, not "absent"), which would break the local path.
89
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))'
90
+ ```
91
+
92
+ It returns `{ mechanism, repo, ghBaseArgs }` and **throws loud** on a malformed / empty / trailing
93
+ `--repo` BEFORE any publish — let that refusal surface; never proceed on a bad target.
94
+
95
+ - **`mechanism: "local"`** (no `--repo`) → publish exactly as today, to `.scratch/` — unchanged.
96
+ - **`mechanism: "github"`** → publish via `gh`, prepending the returned **`ghBaseArgs`** (`-R owner/repo`)
97
+ to every `gh` call, e.g. `gh issue create -R owner/repo --title "…" --body "…" --label backlog`. Apply a
98
+ label from the shared wrxn triage vocab (`ready-for-agent` / `backlog` / `epic`) — a PRD is the parent
99
+ epic, so use `backlog` (or `epic`), not `ready-for-agent`. `gh` fails loud if the target lacks the
100
+ label; do not work around it. The helper's `publishIssue({ target, title, body, label }, gh)` builds
101
+ that exact argv and refuses an off-vocab label — use it as the create boundary.
102
+
103
+ **Remember the published PRD's issue number.** When you later run `to-issues --repo`, point it at the
104
+ **SAME** `owner/repo` so each slice's "Parent" is a real issue number on that tracker (not a dangling
105
+ reference).
@@ -102,3 +102,31 @@ Capture everything resolved during grilling under "established so far" so the wo
102
102
  ## Resuming a previous session
103
103
 
104
104
  If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions.
105
+
106
+ ## `--repo` / cross-repo targeting
107
+
108
+ By default this skill triages issues on the project's configured tracker (see
109
+ `docs/agents/issue-tracker.md`). Passing **`--repo owner/repo`** instead targets a named **GitHub** repo
110
+ for this one invocation (e.g. the kernel or `recon-wrxn`), so you can manage a sibling repo's backlog
111
+ from this session without leaving the pipeline. Resolve the target through the ONE shared helper —
112
+ `.wrxn/tracker-target.cjs` — never hand-roll the parsing:
113
+
114
+ ```bash
115
+ # WITH --repo present: pass the owner/repo value as the trailing argument.
116
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))' "owner/repo"
117
+ # WITHOUT --repo: OMIT the argument entirely (process.argv[1] is undefined → resolves to local).
118
+ # NEVER pass "" — resolveTarget("") THROWS (empty is malformed, not "absent"), which would break the local path.
119
+ node -e 'console.log(JSON.stringify(require("./.wrxn/tracker-target.cjs").resolveTarget(process.argv[1])))'
120
+ ```
121
+
122
+ It returns `{ mechanism, repo, ghBaseArgs }` and **throws loud** on a malformed / empty / trailing
123
+ `--repo` BEFORE any side-effect — let that refusal surface; never proceed on a bad target.
124
+
125
+ - **`mechanism: "local"`** (no `--repo`) → triage exactly as today, editing the `Status:` line in
126
+ `.scratch/` — unchanged.
127
+ - **`mechanism: "github"`** → manage via `gh`, prepending the returned **`ghBaseArgs`** (`-R owner/repo`)
128
+ to every `gh` call: relabel with `gh issue edit -R owner/repo --add-label … --remove-label …`, close
129
+ with `gh issue close -R owner/repo …`, comment with `gh issue comment -R owner/repo …`, and create with
130
+ the helper's `publishIssue({ target, title, body, label }, gh)`. Apply only labels from the shared wrxn
131
+ triage vocab (`ready-for-agent` / `backlog` / `epic`); `gh` fails loud if the target lacks a label — do
132
+ not work around it (no silent mis-label).
@@ -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
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // WRXN cross-repo tracker targeting — the ONE shared resolver behind `to-prd --repo`,
4
+ // `to-issues --repo`, and `triage --repo` (PRD #114 / issue #115). It closes the cross-repo seam ADR
5
+ // 0009 only documented (refs #81 AC#4): from a workspace-install session, `--repo owner/repo` lets the
6
+ // HITL skills spec / slice / triage a sibling GitHub repo (the kernel, recon-wrxn) onto that repo's
7
+ // GitHub tracker — without leaving the four-phase pipeline or hand-filing past the adherence guard.
8
+ //
9
+ // Self-contained: this ships INTO an install and MUST NOT import the kernel lib (node stdlib only).
10
+ // The seam mirrors lib/release-cut.cjs: the decision is a PURE core (resolveTarget — parse / validate /
11
+ // select-mechanism), and the gh create/label/close side-effects run through an INJECTED `gh` boundary,
12
+ // so the decision + emitted spec are unit-testable with NO live gh.
13
+
14
+ // owner/repo: a GitHub-legal ALLOWLIST, not a slash/space blocklist — this is the security chokepoint.
15
+ // The skill wirings steer the agent to hand-compose `gh -R owner/repo` Bash, so the validator is the one
16
+ // place that must reject anything that could change the meaning of that command. Allow ONLY what GitHub
17
+ // itself permits: owner = alphanumerics + hyphens but NOT a leading hyphen (a leading `-` would let `gh`
18
+ // read the whole value as a flag — argument injection); repo = alphanumerics + `.` `_` `-`. This refuses
19
+ // every shell metacharacter (`;` `$()` backtick `|` `&` `>`), embedded whitespace, a trailing slash, an
20
+ // extra path segment, and an empty value by construction — all must refuse loud BEFORE any publish.
21
+ // (Reviewer/security convergent finding — FIX 1.)
22
+ const OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
23
+
24
+ // The shared wrxn triage label vocab — the states already carried on the kernel and recon-wrxn. A label
25
+ // means the same thing across repos; there is NO per-target label config (YAGNI for GitHub siblings).
26
+ // gh fails loud if a target lacks one of these (free remote validation) — publishIssue never swallows it.
27
+ const TRIAGE_LABELS = ['ready-for-agent', 'backlog', 'epic'];
28
+
29
+ /**
30
+ * Resolve the tracker target for an invocation. PURE — the whole cross-repo decision, no side effect.
31
+ * - `--repo` ABSENT (rawRepo nullish) → the install's configured default tracker (today's local-md
32
+ * `.scratch/` path, unchanged). `defaultConfig` IS that default descriptor; the kernel default is
33
+ * local-md (reconfiguring the default is out of scope — the override is per-invocation).
34
+ * - `--repo owner/repo` (valid) → `{ mechanism:'github', repo, ghBaseArgs:['-R', repo] }`. GitHub by
35
+ * construction; NO git-remote inference — tracker TYPE is a config choice, not derivable from a remote
36
+ * (the install proves it: GitHub remote, local-md tracker).
37
+ * - `--repo` PRESENT but malformed / empty / whitespace / trailing → THROW a loud, user-facing Error
38
+ * BEFORE any side-effect, so a bad invocation never half-files (US-5).
39
+ * @param {string|undefined|null} rawRepo the raw `--repo` value (absent ⇒ nullish)
40
+ * @param {{mechanism:string}} [defaultConfig] the install's default tracker descriptor (local-md today)
41
+ * @returns {{mechanism:'local'}|{mechanism:'github',repo:string,ghBaseArgs:string[]}}
42
+ */
43
+ function resolveTarget(rawRepo, defaultConfig = { mechanism: 'local' }) {
44
+ if (rawRepo == null) return defaultConfig; // --repo omitted → the configured default tracker
45
+ if (typeof rawRepo !== 'string' || !OWNER_REPO.test(rawRepo)) {
46
+ throw new Error(
47
+ `--repo must be "owner/repo" (got ${JSON.stringify(rawRepo)}) — e.g. gcunharodrigues/wrxn-kernel`,
48
+ );
49
+ }
50
+ return { mechanism: 'github', repo: rawRepo, ghBaseArgs: ['-R', rawRepo] };
51
+ }
52
+
53
+ /**
54
+ * Publish an issue onto a resolved GitHub target, applying ONE label drawn from the shared wrxn vocab.
55
+ * The `gh` runner is an INJECTED boundary (it executes the built argv) — mirroring release-cut's injected
56
+ * `deps`, so this is unit-testable with no live gh and the emitted spec is asserted over a fake. Guards
57
+ * refuse LOUD before the boundary (a non-github target, or an off-vocab label → no silent mis-label); a
58
+ * gh failure (e.g. the target repo lacks the label) is NOT swallowed — it propagates so a mis-labeled or
59
+ * unfiled issue never lands silently (US-6, US-7).
60
+ * @param {{target:object,title:string,body:string,label:string}} spec
61
+ * @param {(argv:string[])=>any} gh the injected gh runner (the real impl shells `gh` with these args)
62
+ * @returns whatever the gh runner returns
63
+ */
64
+ function publishIssue({ target, title, body, label } = {}, gh) {
65
+ if (!target || target.mechanism !== 'github') {
66
+ throw new Error(`publishIssue targets a github tracker (got mechanism "${target && target.mechanism}")`);
67
+ }
68
+ if (!TRIAGE_LABELS.includes(label)) {
69
+ throw new Error(`label "${label}" is off the shared wrxn vocab — expected one of ${TRIAGE_LABELS.join(' | ')}`);
70
+ }
71
+ if (typeof gh !== 'function') throw new Error('publishIssue requires an injected gh runner');
72
+ const argv = ['issue', 'create', ...target.ghBaseArgs, '--title', title, '--body', body, '--label', label];
73
+ return gh(argv); // the side-effect lives at the boundary; a gh failure propagates loud (no swallow)
74
+ }
75
+
76
+ module.exports = { resolveTarget, publishIssue, TRIAGE_LABELS, OWNER_REPO };
@@ -23,3 +23,23 @@ Read the file at the referenced path. The user will normally pass the path or th
23
23
 
24
24
  To use GitHub or GitLab issues instead, re-run `setup-matt-pocock-skills` and pick the
25
25
  matching tracker — it will rewrite this file from the right template.
26
+
27
+ ## Per-invocation override: `--repo owner/repo` (cross-repo targeting)
28
+
29
+ `to-prd`, `to-issues`, and `triage` accept an optional `--repo owner/repo` flag that targets a
30
+ named **GitHub** repo for that one invocation, instead of this install's default tracker above. It
31
+ exists so the operator can spec / slice / triage a sibling GitHub repo (e.g. the kernel or
32
+ `recon-wrxn`) end-to-end from a workspace-install session, without leaving the four-phase pipeline.
33
+
34
+ - **Absent `--repo`** → the default tracker described above (local-markdown `.scratch/`), unchanged.
35
+ Existing install-local workflows are untouched.
36
+ - **`--repo owner/repo`** → publish/manage on that GitHub repo via `gh`, using the shared wrxn triage
37
+ vocab (`ready-for-agent` / `backlog` / `epic`). The repo is passed explicitly to `gh -R owner/repo`
38
+ — it is **not** inferred from a git remote (the tracker TYPE is a config choice, not the remote).
39
+ - **Malformed / empty / trailing `--repo`** (anything not exactly `owner/repo`) → the skill refuses
40
+ loud *before* any publish, so a bad invocation never half-files.
41
+
42
+ The override is per-invocation only; it never reconfigures this install's default tracker. All three
43
+ skills resolve the target through the one shared `.wrxn/tracker-target.cjs` helper (see those skills'
44
+ "`--repo` / cross-repo targeting" sections), so validation, label handling, and `gh`-arg construction
45
+ are identical across them.