@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 +5 -0
- package/package.json +1 -1
- package/payload/.claude/skills/chat-search/SKILL.md +5 -5
- package/payload/.claude/skills/to-issues/SKILL.md +31 -0
- package/payload/.claude/skills/to-prd/SKILL.md +31 -0
- package/payload/.claude/skills/triage/SKILL.md +28 -0
- package/payload/.wrxn/chat-search.cjs +83 -32
- package/payload/.wrxn/tracker-target.cjs +76 -0
- package/payload/docs/agents/issue-tracker.md +20 -0
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "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,
|
|
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
|
|
|
@@ -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
|
|
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
|
|
@@ -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.
|