@gcunharodrigues/wrxn 0.13.2 → 0.14.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.
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ // WRXN reward module — the learning-moat's value axis (kernel #13 / S2). A per-page Beta-Bernoulli
4
+ // store keyed by wiki-rel page path: { "<wiki-rel>": { s, f } } = good / bad sessions in which the page
5
+ // surfaced. `updateReward(counts, surfacedSet, signal)` is the PURE, deterministic, TOTAL update applied
6
+ // ONCE PER SESSION: +1 credits each surfaced page's success count `s`, −1 credits its failure count `f`,
7
+ // neutral (0) leaves counts untouched. The signal is INJECTED (the math never reads git/clock itself —
8
+ // the impure session-end shell derives ±1 and passes it), mirroring the recon decay-scorer's discipline.
9
+ //
10
+ // SHADOW (S2): this module only WRITES counts. It does NOT compute a reward factor or touch recall
11
+ // ranking — the re-rank is S3, behind a recorded mode constant. Keeping the factor out of S2 makes
12
+ // "shipping S2 is a recall no-op" true by construction.
13
+ //
14
+ // Self-contained: ships into installs alongside the hooks — node stdlib ONLY, NO kernel-lib / recon
15
+ // import (mirrors sidecar.cjs). The update is pure (no IO); only the shell that calls it does git/fs.
16
+
17
+ // Counts are bounded by construction so one lucky (or unlucky) page can't dominate every recall once the
18
+ // factor goes live in S3: each of s/f saturates at COUNT_CAP. The exact cap is a placeholder pending the
19
+ // lift gate (the PRD records caps as gate-tuned, not guessed — like the decay half-life); it exists now
20
+ // only so the store is bounded from day one, never as a tuned value.
21
+ const COUNT_CAP = 1e6;
22
+
23
+ // A valid join key is a wiki-rel page path: a STRING with non-whitespace content (e.g. 'concepts/a.md').
24
+ // Strictly string-typed (a coerced number like 0 is not a path) and non-blank, so garbage in the
25
+ // surfaced set credits nobody. This is also the sec-F3 posture: the value is treated only as a discrete
26
+ // page-identity key, never decomposed or used as anything but a map key.
27
+ function isWikiRelKey(k) {
28
+ return typeof k === 'string' && k.trim().length > 0;
29
+ }
30
+
31
+ // A non-negative finite integer-ish count, defaulting to 0 (totality: garbage in a slot reads as 0).
32
+ function asCount(v) {
33
+ const n = Number(v);
34
+ return Number.isFinite(n) && n > 0 ? n : 0;
35
+ }
36
+
37
+ // Read an existing { s, f } slot defensively (totality: a malformed slot reads as zero evidence).
38
+ function readSlot(counts, key) {
39
+ const slot = counts && typeof counts === 'object' ? counts[key] : null;
40
+ if (!slot || typeof slot !== 'object') return { s: 0, f: 0 };
41
+ return { s: asCount(slot.s), f: asCount(slot.f) };
42
+ }
43
+
44
+ /**
45
+ * The pure Beta-Bernoulli update, applied ONCE PER SESSION (the caller passes the session's surfaced set
46
+ * exactly once). Returns a FRESH counts map — the input is never mutated.
47
+ * signal > 0 → s += 1 for each surfaced page (a good session: new commits, green suite by construction)
48
+ * signal < 0 → f += 1 for each surfaced page (a bad session: a later revert / correction)
49
+ * signal == 0 → no change (neutral: no new commits — nothing to attribute)
50
+ * Equal credit to every page in the surfaced set (attribution v1). TOTAL: any garbage (non-array set,
51
+ * non-object counts, non-numeric signal, malformed slots) yields a sane map and never throws.
52
+ *
53
+ * Optional `opts.discount` ∈ (0,1) decays EVERY prior count toward neutral before this session's signal
54
+ * (non-stationarity: a stale regime is unlearned over the page's lifetime). Out-of-range / garbage →
55
+ * no decay (totality). Off by default — the exact value is gate-tuned, not guessed (PRD).
56
+ *
57
+ * @param {Record<string,{s:number,f:number}>} counts prior per-page counts (treated read-only)
58
+ * @param {Iterable<string>} surfacedSet wiki-rel page keys surfaced this session
59
+ * @param {number} signal +1 good / −1 bad / 0 neutral (injected; never read from git/clock here)
60
+ * @param {{discount?:number}} [opts] optional decay factor in (0,1) applied to prior counts
61
+ * @returns {Record<string,{s:number,f:number}>} a fresh updated counts map
62
+ */
63
+ function updateReward(counts, surfacedSet, signal, opts) {
64
+ // A well-formed, fresh defensive copy of the prior counts (every slot normalized to {s,f} numbers).
65
+ const copyPrior = () => {
66
+ const m = {};
67
+ if (counts && typeof counts === 'object' && !Array.isArray(counts)) {
68
+ for (const k of Object.keys(counts)) {
69
+ if (!isWikiRelKey(k)) continue;
70
+ m[k] = readSlot(counts, k);
71
+ }
72
+ }
73
+ return m;
74
+ };
75
+
76
+ const dir = Number(signal);
77
+ if (!Number.isFinite(dir) || dir === 0) return copyPrior(); // neutral → no update (and no decay)
78
+
79
+ // A discount strictly inside (0,1) decays prior counts toward neutral BEFORE this session's signal;
80
+ // anything else (incl. 1, 0, NaN) → no decay. Applied only on a real (non-neutral) session.
81
+ const d = Number(opts && opts.discount);
82
+ const discount = Number.isFinite(d) && d > 0 && d < 1 ? d : 1;
83
+ const out = copyPrior();
84
+ if (discount !== 1) {
85
+ for (const k of Object.keys(out)) {
86
+ out[k] = { s: out[k].s * discount, f: out[k].f * discount };
87
+ }
88
+ }
89
+
90
+ // A surfaced SET is a collection of page keys. A bare string is NOT a set — Array.from would split it
91
+ // into single characters and credit each as a page (corrupting the store); so anything that isn't an
92
+ // array or a non-string iterable credits nobody (totality), never throws.
93
+ let iterable;
94
+ if (Array.isArray(surfacedSet)) {
95
+ iterable = surfacedSet;
96
+ } else if (surfacedSet != null && typeof surfacedSet !== 'string' && typeof surfacedSet[Symbol.iterator] === 'function') {
97
+ try {
98
+ iterable = Array.from(surfacedSet);
99
+ } catch {
100
+ iterable = [];
101
+ }
102
+ } else {
103
+ iterable = [];
104
+ }
105
+
106
+ const seen = new Set();
107
+ for (const raw of iterable) {
108
+ if (!isWikiRelKey(raw)) continue; // non-string / blank → not a page key, credit nobody (totality)
109
+ const key = raw;
110
+ if (seen.has(key)) continue; // once per page per session (a dupe in the set is a single credit)
111
+ seen.add(key);
112
+ const slot = out[key] || { s: 0, f: 0 };
113
+ if (dir > 0) slot.s = Math.min(slot.s + 1, COUNT_CAP);
114
+ else slot.f = Math.min(slot.f + 1, COUNT_CAP);
115
+ out[key] = slot;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * The reward VALUE AXIS (S3): map a page's Beta-Bernoulli slot `{s,f}` to a centered multiplicative
122
+ * factor for the recall re-rank. Laplace-smoothed posterior mean `(s+1)/(s+f+2)` ∈ (0,1) (neutral 1/2
123
+ * at zero evidence) → factor `2·mean` ∈ (0,2): a proven page (s≫f) lifts toward 2, a disproven page
124
+ * (f≫s) sinks toward 0, and a page with NO evidence maps to EXACTLY 1 so it never moves a rank.
125
+ *
126
+ * BOUNDED by construction: s,f are finite non-negative (and saturate at COUNT_CAP in updateReward), so
127
+ * the mean is strictly inside (0,1) and the factor strictly inside (0,2) — it can never reach 0 or 2,
128
+ * so one lucky page can neither zero out nor double a rank. PURE + TOTAL: garbage / missing slot reads
129
+ * as zero evidence → neutral 1, never a throw, never reads a clock. The same posterior the mechanism
130
+ * gate fixtures lock (a known-useful page outranks a known-useless one).
131
+ *
132
+ * @param {{s:number,f:number}} [slot] a page's reward counts (missing/garbage → zero evidence)
133
+ * @returns {number} the centered reward factor in (0,2); exactly 1 at zero evidence
134
+ */
135
+ function rewardFactor(slot) {
136
+ const s = asCount(slot && slot.s);
137
+ const f = asCount(slot && slot.f);
138
+ const mean = (s + 1) / (s + f + 2); // Laplace posterior mean ∈ (0,1), exactly 1/2 at s=f=0
139
+ return 2 * mean; // centered factor ∈ (0,2), exactly 1 at zero evidence
140
+ }
141
+
142
+ // ── starvedUseful: the learning-moat WATCHDOG (S4) ───────────────────────────────────
143
+ // A pure, deterministic, TOTAL canary over the (reward, surfaced-log) sidecar pair. It counts the
144
+ // STARVED-USEFUL set: pages the reward signal has learned are useful (high posterior) yet recall RARELY
145
+ // surfaces (low surfaced-count) — because they sit below recon's floor, where a kernel-side re-rank
146
+ // cannot lift them. A growing set means the cheap kernel-side approach has hit its ceiling; the count is
147
+ // the data-driven signal toward graduating a recon reward term (option b). This module only COMPUTES the
148
+ // count — the handoff emission lives in the session-end shell (synapse-engine), the SAME channel as
149
+ // harvest's curation-debt nudge. Read-only: it never mutates the sidecars or touches recall ranking.
150
+
151
+ // "High reward" REUSES the value axis's posterior (rewardFactor = 2·mean ⇒ mean = factor/2 — never a
152
+ // second notion). R_HIGH is the posterior-mean bar for "learned useful"; S_LOW the surfaced-count bar
153
+ // for "rarely surfaced". Both are PLACEHOLDERS pending the lift gate — recorded with the gate verdict,
154
+ // exactly like COUNT_CAP and the decay half-life (the PRD records thresholds as gate-tuned, not guessed).
155
+ // They exist now only so the canary is bounded and deterministic from day one, never as tuned values.
156
+ const R_HIGH = 0.75; // posterior mean ≥ 0.75 (⇔ rewardFactor ≥ 1.5) — clearly net-positive evidence
157
+ const S_LOW = 1; // surfaced in ≤ 1 recorded session — recall almost never brings it up
158
+
159
+ // Build a { page → number-of-sessions-that-surfaced-it } map from the surfaced-log
160
+ // ({ "<session_id>": ["<wiki-rel>", …] }). Each session credits a page AT MOST ONCE (a within-session
161
+ // dupe is one surfacing), mirroring recall-surface's per-session de-dup. TOTAL: a non-object log, a
162
+ // non-array session entry, or a non-string/blank page key contributes nothing — never throws.
163
+ function surfacedCounts(surfaced) {
164
+ const counts = Object.create(null);
165
+ if (!surfaced || typeof surfaced !== 'object' || Array.isArray(surfaced)) return counts;
166
+ for (const sid of Object.keys(surfaced)) {
167
+ const entry = surfaced[sid];
168
+ if (!Array.isArray(entry)) continue;
169
+ const seen = new Set();
170
+ for (const raw of entry) {
171
+ if (!isWikiRelKey(raw) || seen.has(raw)) continue;
172
+ seen.add(raw);
173
+ counts[raw] = (counts[raw] || 0) + 1;
174
+ }
175
+ }
176
+ return counts;
177
+ }
178
+
179
+ /**
180
+ * PURE watchdog: count the STARVED-USEFUL pages over the (reward, surfaced) sidecar pair. A page is
181
+ * starved-useful when BOTH hold:
182
+ * · posterior(reward[page]) ≥ rHigh — learned useful (the SAME posterior the value axis uses)
183
+ * · surfaced-count(page) ≤ sLow — rarely surfaced across the log (sits below recon's floor)
184
+ * Returns { count, pages } with `pages` sorted (DETERMINISTIC) and `count = pages.length`. TOTAL: any
185
+ * garbage (non-object reward/surfaced, malformed slot, garbage opts) yields { count: 0, pages: [] } and
186
+ * never throws. Thresholds default to the named constants R_HIGH / S_LOW; opts may override them for the
187
+ * gate's tuning (out-of-range / non-finite → the constant default).
188
+ *
189
+ * @param {Record<string,{s:number,f:number}>} reward per-page Beta-Bernoulli counts (read-only)
190
+ * @param {Record<string,string[]>} surfaced per-session surfaced-log (read-only)
191
+ * @param {{rHigh?:number, sLow?:number}} [opts] optional threshold overrides (gate-tuned)
192
+ * @returns {{count:number, pages:string[]}}
193
+ */
194
+ function starvedUseful(reward, surfaced, opts) {
195
+ const rh = Number(opts && opts.rHigh);
196
+ const rHigh = Number.isFinite(rh) && rh > 0 && rh < 1 ? rh : R_HIGH;
197
+ const sl = Number(opts && opts.sLow);
198
+ const sLow = Number.isFinite(sl) && sl >= 0 ? sl : S_LOW;
199
+
200
+ const counts = surfacedCounts(surfaced);
201
+ const pages = [];
202
+ if (reward && typeof reward === 'object' && !Array.isArray(reward)) {
203
+ for (const key of Object.keys(reward)) {
204
+ if (!isWikiRelKey(key)) continue;
205
+ const posterior = rewardFactor(reward[key]) / 2; // SAME posterior as the value axis (factor = 2·mean)
206
+ if (posterior < rHigh) continue; // not learned-useful
207
+ if ((counts[key] || 0) > sLow) continue; // surfaced often enough — recall already reaches it
208
+ pages.push(key);
209
+ }
210
+ }
211
+ pages.sort();
212
+ return { count: pages.length, pages };
213
+ }
214
+
215
+ // ── selectRewardMode: the SHIP GATE (S5 / kernel #16) ─────────────────────────────────
216
+ // Mirrors the recon decay-scorer's selectDecayMode(verdict) → SHIPPED_DECAY_MODE. The reward re-rank in
217
+ // recall-surface ships behind a single recorded mode constant; that constant is DERIVED from the lift
218
+ // gate's verdict, never hard-coded. The re-rank goes 'live' ONLY on a PASSING verdict; a failing OR an
219
+ // absent/garbage verdict stays 'shadow' (the safe default — a learned ranker is never silently enabled).
220
+ // A passing verdict is the LITERAL `{ pass: true }`: only an explicit, recorded pass flips recall on, so
221
+ // a malformed or missing verdict can never leak 'live'. PURE + TOTAL — no IO, no clock, never throws.
222
+ function selectRewardMode(verdict) {
223
+ return verdict && typeof verdict === 'object' && verdict.pass === true ? 'live' : 'shadow';
224
+ }
225
+
226
+ // The RECORDED production verdict the shipped mode is derived from (the kernel analogue of the decay
227
+ // gate's recorded PASS). It is NOT a passing verdict: ① ships in SHADOW because there is no real
228
+ // accumulated session corpus yet (no logged sessions → the lift gate cannot be run on production data).
229
+ // The mechanism gate + lift-replay harness (docs/eval/0001-reward-lift-gate.md) prove the MECHANISM on
230
+ // synthetic fixtures; the PRODUCTION flip to 'live' additionally requires (a) a passing lift verdict on
231
+ // REAL accumulated sessions AND (b) operator ratification of the git-only outcome signal (it is unsound
232
+ // for `--no-verify` / suite-less installs). Frozen so the recorded verdict cannot be mutated in place.
233
+ const RECORDED_REWARD_VERDICT = Object.freeze({ pass: false, reason: 'insufficient-data' });
234
+
235
+ module.exports = {
236
+ updateReward,
237
+ rewardFactor,
238
+ starvedUseful,
239
+ selectRewardMode,
240
+ RECORDED_REWARD_VERDICT,
241
+ COUNT_CAP,
242
+ R_HIGH,
243
+ S_LOW,
244
+ };
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN session-end reward shell — the learning-moat's outcome attributor (kernel #13 / S2).
5
+ // SessionEnd. A thin, FAIL-OPEN hook that turns "did this session ship durable work?" into a per-page
6
+ // reward update. It reads the start-HEAD baseline (stamped by session-start), this session's surfaced
7
+ // set (the per-session surfaced-log .wrxn/surfaced.json by session id), derives a GIT-GROUNDED outcome
8
+ // signal, and persists updated Beta-Bernoulli counts (.wrxn/reward.json) via the shared coalesceSidecar
9
+ // helper. The pure reward math lives in reward.cjs; this shell only supplies git facts + IO.
10
+ //
11
+ // SIGNAL (resolved): Article III makes a landed commit green by construction (the commit gate runs the
12
+ // suite), so the production signal is git-only and cheap — the suite is NEVER run here:
13
+ // · new non-revert commit(s) since baseline ⇒ +1 (good)
14
+ // · a `git revert` / recorded correction of this session's work ⇒ −1 (bad)
15
+ // · no new commits ⇒ neutral (no update)
16
+ // The pure deriveSignal takes the new-commit subjects explicitly, so a future stricter signal can be
17
+ // swapped in without touching the math.
18
+ //
19
+ // SHADOW: this shell only WRITES counts — it never reads or writes recall ranking (the re-rank is S3).
20
+ // Self-contained: ships into installs, MUST NOT import the kernel lib — node stdlib ONLY (+ the
21
+ // co-located sidecar.cjs / reward.cjs siblings). Fail-open: any fault emits {} (no update), never blocks
22
+ // a session closing.
23
+ //
24
+ // Contract: SessionEnd event JSON on stdin → {} on stdout (exit 0).
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const { execFileSync } = require('child_process');
29
+ const { coalesceSidecar } = require('./sidecar.cjs');
30
+ const { updateReward } = require('./reward.cjs');
31
+ const { prune, pruneFiles, LOG_DIRS } = require('./prune.cjs');
32
+
33
+ const BASELINE_DIR_REL = ['.wrxn', 'baseline'];
34
+ const SURFACED_REL = ['.wrxn', 'surfaced.json'];
35
+ const REWARD_REL = ['.wrxn', 'reward.json'];
36
+
37
+ // safeId — canonicalize a session id used as a FILESYSTEM PATH component (sec-F1): lowercase, collapse every
38
+ // non-alnum run to '-', trim, cap length. The baseline marker is keyed by session id; a raw id like
39
+ // '../../evil' would traverse out of .wrxn/baseline. REPLICATED byte-identically from session-start.cjs /
40
+ // code-intel-push.cjs — each install-only hook is self-contained (node stdlib only, no shared import, exactly
41
+ // as secretScan is duplicated across the adapters). session-start sanitizes the SAME way, so the marker the
42
+ // reward shell reads + stamps is exactly the one session-start wrote. NB: the surfaced-log MAP KEY stays RAW
43
+ // (a JSON key is not a path; raw preserves join parity with the S1 surfaced-log writer) — only paths sanitize.
44
+ function safeId(sid) {
45
+ return String(sid || 'session')
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 48) || 'session';
50
+ }
51
+
52
+ // Walk up from cwd / CLAUDE_PROJECT_DIR to the install root carrying wrxn.install.json (mirrors the
53
+ // sibling session hooks). Returns null when no install is found (the hook then no-ops, fail-open).
54
+ function findInstallRoot(startDir) {
55
+ let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
56
+ for (let i = 0; i < 12; i++) {
57
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
58
+ const up = path.dirname(dir);
59
+ if (up === dir) break;
60
+ dir = up;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // A commit subject is a REVERT when git's own `git revert` wrote it: "Revert "<subject>"". Used to flip
66
+ // a session bad — its work did not durably hold. Matched on the canonical prefix git emits.
67
+ function isRevertSubject(s) {
68
+ return /^Revert "/.test(String(s || ''));
69
+ }
70
+
71
+ /**
72
+ * PURE: derive the outcome signal from the new commits made since the session's start-HEAD baseline.
73
+ * newCommits present and none is a revert ⇒ +1 (good)
74
+ * any new commit is a `git revert` ⇒ −1 (bad — the session work was corrected)
75
+ * no new commits ⇒ 0 (neutral — nothing to attribute)
76
+ * TOTAL: any garbage (null, non-array) is treated as "no new commits" → neutral, never throws.
77
+ * @param {{newCommits?: string[]}} facts the git facts gathered by the shell (subjects of baseline..HEAD)
78
+ * @returns {-1|0|1}
79
+ */
80
+ function deriveSignal(facts) {
81
+ const list = facts && Array.isArray(facts.newCommits) ? facts.newCommits : [];
82
+ if (!list.length) return 0; // no new commits → neutral
83
+ if (list.some(isRevertSubject)) return -1; // a revert flips the session bad
84
+ return +1; // new non-revert work, green by construction (Art. III commit gate)
85
+ }
86
+
87
+ // Read the start-HEAD baseline marker for `sessionId` → { head, rewarded }, or null (absent/malformed →
88
+ // no baseline, the shell then no-ops: without a known start commit "commits this session" is undefined).
89
+ // `rewarded` is the once-per-session guard: set true after this session's first credit. Fail-open.
90
+ function readBaseline(root, sessionId) {
91
+ try {
92
+ const raw = fs.readFileSync(path.join(root, ...BASELINE_DIR_REL, safeId(sessionId)), 'utf8');
93
+ const rec = JSON.parse(raw);
94
+ const head = rec && typeof rec.head === 'string' ? rec.head.trim() : '';
95
+ if (!head) return null;
96
+ return { head, rewarded: !!(rec && rec.rewarded) };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // Mark the session's baseline marker as rewarded (the once-per-session guard) so a second SessionEnd for
103
+ // the same session is a no-op. Best-effort: a failure to stamp the guard must not throw — at worst the
104
+ // next run re-credits, which the coalesced no-op already softens for an unchanged surfaced set.
105
+ function markRewarded(root, sessionId) {
106
+ try {
107
+ const file = path.join(root, ...BASELINE_DIR_REL, safeId(sessionId));
108
+ const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
109
+ rec.rewarded = true;
110
+ fs.writeFileSync(file, JSON.stringify(rec));
111
+ } catch {
112
+ /* best-effort guard */
113
+ }
114
+ }
115
+
116
+ // Read THIS session's surfaced set from the per-session surfaced-log (.wrxn/surfaced.json).
117
+ // sec-F3 (carried from S1): every value is treated STRICTLY as a join key — a page-identity STRING —
118
+ // and is NEVER passed to fs as a path to open. We join on the key space only. Returns a de-duplicated
119
+ // array of string keys, or [] (absent / malformed / no entry for this session). Fail-open.
120
+ function readSurfacedSet(root, sessionId) {
121
+ let map;
122
+ try {
123
+ map = JSON.parse(fs.readFileSync(path.join(root, ...SURFACED_REL), 'utf8'));
124
+ } catch {
125
+ return []; // absent or malformed surfaced-log → nothing to attribute
126
+ }
127
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return [];
128
+ const entry = map[sessionId];
129
+ if (!Array.isArray(entry)) return [];
130
+ const seen = new Set();
131
+ const out = [];
132
+ for (const v of entry) {
133
+ if (typeof v !== 'string') continue; // join keys are strings; ignore anything else (never coerce)
134
+ const key = v;
135
+ if (!key || seen.has(key)) continue;
136
+ seen.add(key);
137
+ out.push(key);
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // Production git facts: the subjects of the commits made since the session's start-HEAD baseline, i.e.
143
+ // `git log <baseline>..HEAD --format=%s`. An empty range (HEAD unchanged) → [] → neutral. Rooted at the
144
+ // install; fail-open (no git / bad range → no facts → neutral). NEVER runs the suite (Art. III makes a
145
+ // landed commit green by construction — the signal is git-only and cheap).
146
+ function gitFactsSince(root, baselineHead) {
147
+ // sec-F2: the baseline head comes from an on-disk marker that could be corrupt — only a sha-SHAPED value
148
+ // may become a git revision. A missing or non-sha head (option-/ref-expression-shaped) ⇒ neutral, never an
149
+ // arg to `git log` (defense-in-depth: execFile already blocks the shell; this blocks ref/option abuse).
150
+ if (!baselineHead || !/^[0-9a-f]{7,40}$/.test(baselineHead)) return { newCommits: [] };
151
+ try {
152
+ const out = execFileSync('git', ['log', `${baselineHead}..HEAD`, '--format=%s'], {
153
+ cwd: root,
154
+ encoding: 'utf8',
155
+ stdio: ['ignore', 'pipe', 'ignore'],
156
+ });
157
+ const newCommits = String(out || '')
158
+ .split('\n')
159
+ .map((l) => l.trim())
160
+ .filter(Boolean);
161
+ return { newCommits };
162
+ } catch {
163
+ return { newCommits: [] }; // unknown baseline sha / no git → treat as no new commits (neutral)
164
+ }
165
+ }
166
+
167
+ /**
168
+ * The testable persist core. Reads the session's start-HEAD baseline + surfaced set, derives the
169
+ * git-grounded signal (git facts injected in tests, gathered from real git in production), applies the
170
+ * pure Beta-Bernoulli update ONCE for the session, and persists the updated counts to .wrxn/reward.json
171
+ * via coalesceSidecar. Returns {} always. FAIL-OPEN: any fault → {} (no update), never blocks close.
172
+ *
173
+ * No-op (writes nothing) when: no session id, no surfaced pages this session, no baseline, or a NEUTRAL
174
+ * signal. SHADOW: it only writes counts — it never touches recall ranking.
175
+ * @param {{payload?:object, root:string, gitFacts?:object}} opts
176
+ * @returns {object} always {}
177
+ */
178
+ function run({ payload, root, gitFacts } = {}) {
179
+ try {
180
+ const sessionId = payload && typeof payload.session_id === 'string' ? payload.session_id : '';
181
+ if (!root || !sessionId) return {};
182
+
183
+ const surfaced = readSurfacedSet(root, sessionId);
184
+ if (!surfaced.length) return {}; // nothing surfaced this session → nothing to attribute
185
+
186
+ const baseline = readBaseline(root, sessionId);
187
+ if (!baseline) return {}; // no exact start commit → "commits this session" is undefined → skip
188
+ if (baseline.rewarded) return {}; // ONCE PER SESSION: already credited → do not double-count
189
+
190
+ const facts = gitFacts || gitFactsSince(root, baseline.head);
191
+ const signal = deriveSignal(facts);
192
+ if (!signal) return {}; // neutral (no new commits) → no update, no write
193
+
194
+ // Persist via the shared coalesced helper: read current counts, apply the pure update once, rewrite.
195
+ // The update is pure (produces a fresh map); the mutate adopts that map and reports whether it changed.
196
+ // rev-F2: `wrote` is coalesceSidecar's ACTUAL return (did the rewrite land on disk), NOT the mutate's
197
+ // computed delta — so a refuse/fail AFTER the mutate (secret-scan refusal, EISDIR, unwritable path) does
198
+ // not arm the once-per-session guard below, and the dropped attribution stays re-creditable on retry.
199
+ const wrote = coalesceSidecar(path.join(root, ...REWARD_REL), (map) => {
200
+ // The discount is OFF in S2 (gate-tuned later, per the PRD); pass no opts → no decay.
201
+ const next = updateReward(map, surfaced, signal);
202
+ const before = JSON.stringify(map);
203
+ // adopt next into the map object the helper will serialize
204
+ for (const k of Object.keys(map)) delete map[k];
205
+ Object.assign(map, next);
206
+ return JSON.stringify(map) !== before; // write only when the counts actually changed
207
+ });
208
+
209
+ // Mark the session credited so a second SessionEnd is a no-op (once-per-session). Only when a write
210
+ // actually happened — if the helper refused (corrupt/secret), leave the guard unset so a healthy
211
+ // retry can still attribute.
212
+ if (wrote) markRewarded(root, sessionId);
213
+ } catch {
214
+ // fail-open: a reward-update fault must never block a session closing.
215
+ }
216
+ return {};
217
+ }
218
+
219
+ function main() {
220
+ let consumed = '';
221
+ try {
222
+ consumed = fs.readFileSync(0, 'utf8');
223
+ } catch {
224
+ /* no stdin → nothing to attribute */
225
+ }
226
+ let payload = {};
227
+ try {
228
+ payload = consumed.trim() ? JSON.parse(consumed) : {};
229
+ } catch {
230
+ payload = {};
231
+ }
232
+ const root = findInstallRoot(payload && payload.cwd);
233
+ // Rolling retention (C1 / #34): bound the append-only intel logs by age + count at session end, BEFORE
234
+ // (and independent of) the reward attribution — which returns early in the common case. prune is itself
235
+ // fail-open; the extra guard keeps a retention fault from ever blocking a session closing.
236
+ if (root) {
237
+ try {
238
+ for (const rel of LOG_DIRS) prune(path.join(root, rel));
239
+ // S2 (#35): the event source writes one file per session, so after the within-file pass also GC
240
+ // whole STALE / drained event files (the within-file prune can bound records but never delete a file).
241
+ pruneFiles(path.join(root, '.wrxn', 'events'));
242
+ } catch {
243
+ /* retention is best-effort — never block shutdown */
244
+ }
245
+ }
246
+ let out = {};
247
+ try {
248
+ if (root) out = run({ payload, root });
249
+ } catch {
250
+ out = {}; // fail-open: never block a session closing
251
+ }
252
+ process.stdout.write(JSON.stringify(out || {}));
253
+ process.exit(0);
254
+ }
255
+
256
+ if (require.main === module) {
257
+ try {
258
+ main();
259
+ } catch {
260
+ process.stdout.write('{}');
261
+ process.exit(0);
262
+ }
263
+ }
264
+
265
+ module.exports = { deriveSignal, run, readSurfacedSet, readBaseline, gitFactsSince, findInstallRoot };
@@ -17,12 +17,82 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { execFileSync } = require('child_process');
20
21
 
21
22
  function emit(envelope) {
22
23
  process.stdout.write(JSON.stringify(envelope));
23
24
  process.exit(0);
24
25
  }
25
26
 
27
+ // ── S2 / #13: the start-HEAD baseline (the reward signal's anchor) ────────────────
28
+ // session-start records the session's start commit (git HEAD) into a tiny per-session marker so the
29
+ // session-end reward shell can compute "commits made THIS session" EXACTLY (HEAD-then vs HEAD-now). The
30
+ // git resolver is injected so the marker-writing core is unit-tested with no real repo; production
31
+ // resolves the real HEAD rooted at the install. Wholly fail-open: any fault → no marker, never a throw,
32
+ // orientation always proceeds. The marker dir is STATE (runtime, gitignored), keyed by session id.
33
+
34
+ const BASELINE_DIR_REL = ['.wrxn', 'baseline'];
35
+
36
+ // safeId — canonicalize a session id used as a FILESYSTEM PATH component (sec-F1): lowercase, collapse every
37
+ // non-alnum run to '-', trim, cap length. A raw session id concatenated into a path is a traversal surface;
38
+ // this keeps the marker INSIDE .wrxn/baseline. REPLICATED byte-identically from code-intel-push.cjs and the
39
+ // session-end reward shell — each install-only hook is self-contained (node stdlib only, no shared import,
40
+ // exactly as secretScan is duplicated across the adapters). The reward shell sanitizes the SAME way, so the
41
+ // baseline marker round-trips (writer here ↔ reader there) and the read can never miss what this wrote.
42
+ function safeId(sid) {
43
+ return String(sid || 'session')
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, '-')
46
+ .replace(/^-+|-+$/g, '')
47
+ .slice(0, 48) || 'session';
48
+ }
49
+
50
+ // Resolve the install repo's current git HEAD sha. Returns the sha string, or null when there is no git
51
+ // / no repo (fail-open). Rooted at the install so a nested cwd can't resolve a different repo's HEAD.
52
+ function resolveGitHead(root) {
53
+ try {
54
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], {
55
+ cwd: root,
56
+ encoding: 'utf8',
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ });
59
+ const sha = String(out || '').trim();
60
+ return sha || null;
61
+ } catch {
62
+ return null; // no git binary / not a repo / detached with no commit → no baseline (fail-open)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Stamp the session's start HEAD into <root>/.wrxn/baseline/<sessionId> as { head, at }. Returns true
68
+ * when a marker was written, false on any fail-open path (no root/session, unresolvable HEAD, unwritable).
69
+ * The HEAD resolver is injected (tests pass a stub); production uses resolveGitHead. NEVER throws.
70
+ * @param {string} root install root
71
+ * @param {string} sessionId the session id (marker key)
72
+ * @param {{resolveHead?:Function, now?:Function}} [opts]
73
+ * @returns {boolean}
74
+ */
75
+ function stampStartHead(root, sessionId, opts = {}) {
76
+ try {
77
+ if (!root || !sessionId) return false;
78
+ const resolveHead = opts.resolveHead || (() => resolveGitHead(root));
79
+ let head;
80
+ try {
81
+ head = resolveHead();
82
+ } catch {
83
+ return false; // resolver threw → fail-open, no marker
84
+ }
85
+ if (!head || typeof head !== 'string') return false; // unresolvable HEAD → no baseline
86
+ const now = opts.now || Date.now;
87
+ const dir = path.join(root, ...BASELINE_DIR_REL);
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ fs.writeFileSync(path.join(dir, safeId(sessionId)), JSON.stringify({ head, at: now() }));
90
+ return true;
91
+ } catch {
92
+ return false; // best-effort: a baseline-stamp fault must never block session orientation
93
+ }
94
+ }
95
+
26
96
  // Walk up from CLAUDE_PROJECT_DIR (or cwd) to the install root carrying wrxn.install.json.
27
97
  function findInstallRoot(startDir) {
28
98
  let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -138,11 +208,26 @@ function main() {
138
208
  } catch {
139
209
  /* no stdin → still try to orient */
140
210
  }
141
- void consumed; // SessionStart carries no field we need beyond the install context
211
+ // SessionStart carries the session id used to key the start-HEAD baseline marker (#13). Parse
212
+ // defensively; a malformed payload simply yields no session id (the baseline is then skipped).
213
+ let event = {};
214
+ try {
215
+ event = consumed.trim() ? JSON.parse(consumed) : {};
216
+ } catch {
217
+ event = {};
218
+ }
142
219
 
143
220
  const root = findInstallRoot();
144
221
  if (!root) emit({});
145
222
 
223
+ // Stamp the session's start HEAD so the session-end reward shell can attribute commits made THIS
224
+ // session (#13 / S2). Fail-open: any fault is swallowed and orientation proceeds unchanged.
225
+ try {
226
+ if (event && event.session_id) stampStartHead(root, event.session_id);
227
+ } catch {
228
+ /* never block orientation on the baseline stamp */
229
+ }
230
+
146
231
  // Hold for an in-flight SessionEnd synth (auto-memory-03) so a back-to-back /clear resumes on the
147
232
  // FRESH baton, bounded by the crash safety-cap. Fail-open: any fault here must not block orientation.
148
233
  try {
@@ -176,4 +261,4 @@ if (require.main === module) {
176
261
  }
177
262
  }
178
263
 
179
- module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS };
264
+ module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL };