@gcunharodrigues/wrxn 0.20.0 → 0.20.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
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"
@@ -25,6 +25,11 @@ are global slash-skills with no local file; the promote is yours). Follow these
25
25
  opens a PR, and arms auto-merge (`gh pr merge --auto --squash`) — no settings file, no env flag, no
26
26
  GitHub clicks. `--branch` defaults to the current branch; pass `--base` if the trunk is not `main`.
27
27
  Run `wrxn ship --dry-run` first if you want to preview the exact promote plan before it runs.
28
+ - **Multi-issue PR bodies — one closing keyword PER issue.** When the PR resolves more than one
29
+ issue, write a closing keyword before EACH number (`Closes #104, closes #105`, or one `Closes #N`
30
+ per line). GitHub auto-closes only the issue directly after a keyword, so a comma/`+` list after a
31
+ single keyword (`Closes #104, #105`) closes ONLY the first — trailing slices leak open (real: PR
32
+ #106 left #105 open). Applies on squash-merge too (GitHub reads the PR body + the squash message).
28
33
  3. **Confirm auto-merge is armed** (e.g. `gh pr view --json autoMergeRequest` shows it enabled). The
29
34
  server-enforced CI ruleset is now the authority: GitHub merges to the trunk the instant CI is green.
30
35
  You never merge by hand and you never push directly to the trunk.
@@ -49,6 +49,91 @@ function findInstallRoot(startDir) {
49
49
  return null;
50
50
  }
51
51
 
52
+ // ── skip-log (#104) ──────────────────────────────────────────────────────────────
53
+ // The #45 `wx` claim blocks a re-spawn SILENTLY, so a benign dedup skip is invisible in the synth log.
54
+ // When a fire skips because the marker is already present, append ONE row to .wrxn/continuity/.synth.log so
55
+ // the dedup is diagnosable. The row matches memory-synth.cjs's writeSynthLog shape EXACTLY — tab-separated
56
+ // six fields: ISO timestamp, session id, task `handoff`, engine `-`, `attempts=0`, outcome `skip`. The
57
+ // session id is sanitized via the same idiom as memory-synth's sanitizeLogField (strip C0/C1 control chars —
58
+ // which INCLUDES tab \x09 and newline \x0a — then length-cap) so a hostile id can't forge extra rows. `skip`
59
+ // is a NON-failure token (the #51 staleness guard treats only no-engine/error as rot), so a dedup never
60
+ // false-warns. Self-contained — node stdlib only, NO kernel import; sanitizeLogField is duplicated by design
61
+ // (every install-only hook is standalone, exactly as safeId is replicated across the hooks).
62
+ const SYNTH_LOG = '.synth.log';
63
+ const LOG_FIELD_MAX = 64;
64
+
65
+ function sanitizeLogField(v) {
66
+ // eslint-disable-next-line no-control-regex
67
+ return String(v == null ? '' : v).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, LOG_FIELD_MAX);
68
+ }
69
+
70
+ // ── content-versioned claim (#105) ───────────────────────────────────────────────
71
+ // Slice 1 (#104) released the `.spawned-<sid>` marker on SessionStart so a resumed session's later end could
72
+ // re-synth. When that release is MISSED (e.g. a multi-terminal continuation whose new process never released
73
+ // the claim), the existence-only marker freezes the baton with no recovery. Slice 2 makes the dedup
74
+ // CONTENT-AWARE: stamp the marker with the session transcript's byte size (+ a timestamp) at claim time, then
75
+ // on a marker-present end re-arm the synth when the transcript has GROWN materially since the stamp — a
76
+ // self-heal that needs no SessionStart release. Any fault (absent/unreadable transcript path, stat fault,
77
+ // unparseable marker) falls back to slice 1's existence-only claim and is fully fail-open (never throws,
78
+ // never double-fires on a fault). Self-contained — node stdlib only, NO kernel import.
79
+ //
80
+ // GROWTH_THRESHOLD — the byte growth that counts as "did real work since the stamp". A same-end double-fire
81
+ // appends NOTHING to the transcript (~0 bytes); a resumed session that did real work appends KB–MB of JSONL
82
+ // (each transcript turn — uuid/parentUuid/sessionId/timestamp/message — is hundreds of bytes to several KB).
83
+ // 1 KiB sits comfortably above zero / trailing-byte jitter and well below a single real exchange, so it
84
+ // cleanly separates "no new work" (skip) from "genuine continuation" (re-arm). Conservative by design: the
85
+ // re-arm fires only on a POSITIVE growth signal, so any uncertainty defaults to skip (never double-fire).
86
+ const GROWTH_THRESHOLD = 1024;
87
+
88
+ // The transcript byte size from the SessionEnd payload's transcript_path, or null on ANY fault (absent path,
89
+ // ENOENT, EACCES, …). null = "size unknown" → the caller falls back to the existence-only claim (fail-open).
90
+ function transcriptSize(transcriptPath) {
91
+ try {
92
+ if (!transcriptPath) return null;
93
+ return fs.statSync(transcriptPath).size;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // The marker payload stamped at claim/re-arm time: the transcript size + an ISO timestamp. A null size
100
+ // (unreadable transcript) is stamped as-is, so a later read yields no baseline → existence-only fallback.
101
+ function stampContent(size) {
102
+ return JSON.stringify({ size: typeof size === 'number' ? size : null, at: new Date().toISOString() });
103
+ }
104
+
105
+ // The size stamped into an existing marker, or null when there is no usable baseline — a #104 zero-byte
106
+ // marker, unparseable JSON, or a null/non-number size. null → the caller falls back to existence-only (skip),
107
+ // so a corrupt/legacy marker can never trigger a re-arm (any uncertainty defaults to skip, never double-fire).
108
+ function readStampedSize(markerPath) {
109
+ try {
110
+ const raw = fs.readFileSync(markerPath, 'utf8');
111
+ if (!raw.trim()) return null;
112
+ const parsed = JSON.parse(raw);
113
+ return typeof parsed.size === 'number' ? parsed.size : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ // Append exactly one tab-separated `skip` row for a marker-present dedup. Best-effort + FAIL-OPEN: a logging
120
+ // fault is swallowed so it can NEVER affect the dedup (the diagnosability log must not become a failure mode).
121
+ function appendSkipLog(dir, sessionId) {
122
+ try {
123
+ const line = [
124
+ new Date().toISOString(),
125
+ sanitizeLogField(sessionId) || '-', // untrusted stash value — strip control chars, cap length.
126
+ 'handoff',
127
+ '-',
128
+ 'attempts=0',
129
+ 'skip',
130
+ ].join('\t');
131
+ fs.appendFileSync(path.join(dir, SYNTH_LOG), line + '\n');
132
+ } catch {
133
+ /* the skip log is best-effort — a write fault must never affect the dedup */
134
+ }
135
+ }
136
+
52
137
  /**
53
138
  * The testable core. Given the SessionEnd payload, the install root, the env (for the recursion guard),
54
139
  * and an injectable `spawn`, it stashes the payload + writes the pending markers and launches the synth
@@ -68,14 +153,52 @@ function run({ payload, root, env = process.env, spawn: spawnFn = spawn }) {
68
153
  // Once-per-session claim (#45): the harness fires SessionEnd more than once per session, so an
69
154
  // unguarded hook launches N synths that race on one shared `.pending`. CLAIM the session ATOMICALLY
70
155
  // with an exclusive create (`wx` throws EEXIST if the marker exists) BEFORE staging markers / spawning:
71
- // the FIRST fire creates the marker and proceeds; every later fire for the same session throws → the
72
- // outer catch returns {} (spawns nothing, writes no markers). Race-safe across the separate hook
73
- // processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must OUTLIVE the synth, so it
74
- // is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A missing/empty session_id
75
- // cannot be deduped → preserve today's spawn-every-time behavior for that path.
156
+ // the FIRST fire creates+stamps the marker and proceeds; a later fire for the same session throws EEXIST,
157
+ // handled below by the content-aware dedup (#105: re-arm if the transcript grew, else skip). Race-safe
158
+ // across the separate hook processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must
159
+ // OUTLIVE the synth, so it is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A
160
+ // missing/empty session_id cannot be deduped → preserve today's spawn-every-time behavior for that path.
76
161
  const sid = payload && payload.session_id;
77
162
  if (sid) {
78
- fs.closeSync(fs.openSync(path.join(dir, `.spawned-${safeId(sid)}`), 'wx'));
163
+ const markerPath = path.join(dir, `.spawned-${safeId(sid)}`);
164
+ const currentSize = transcriptSize(payload && payload.transcript_path); // null on any fault → fallback
165
+ try {
166
+ // Atomic exclusive claim (wx is TOCTOU-free across the separate hook processes, #45) — and STAMP it
167
+ // with the transcript byte size + a timestamp (#105), so a later marker-present end can tell a genuine
168
+ // continuation (transcript grew) from a same-end duplicate. Write to the just-claimed fd so the create
169
+ // and the stamp are one operation on the file we exclusively own.
170
+ const fd = fs.openSync(markerPath, 'wx');
171
+ try {
172
+ fs.writeSync(fd, stampContent(currentSize));
173
+ } finally {
174
+ fs.closeSync(fd);
175
+ }
176
+ } catch (err) {
177
+ if (err && err.code === 'EEXIST') {
178
+ // The session is already claimed — a same-instance double-fire (#45) OR a resume's later end before
179
+ // SessionStart released the marker. Content-aware dedup (#105): re-arm ONLY on a positive growth
180
+ // signal — if the transcript grew past the threshold since the stamp, this is a genuine continuation
181
+ // (a missed release), so RE-STAMP to the new size and fall through to spawn (the baton un-freezes).
182
+ // Otherwise — no material growth, OR an indeterminate baseline/size from a fault — it is a same-end
183
+ // duplicate: log ONE benign `skip` row (#104) and no-op. Any uncertainty defaults to skip, so a
184
+ // stat/read fault can never double-fire the synth.
185
+ const stampedSize = readStampedSize(markerPath);
186
+ if (stampedSize != null && currentSize != null && currentSize - stampedSize > GROWTH_THRESHOLD) {
187
+ try {
188
+ fs.writeFileSync(markerPath, stampContent(currentSize)); // re-stamp the new baseline
189
+ } catch {
190
+ /* re-stamp is best-effort: a write fault must not block the re-arm — still spawn (PRD: the rare
191
+ double-spawn is bounded by the in-flight .pending markers + the synth's once-per-session guard) */
192
+ }
193
+ // fall through (do NOT return) → stash the pending markers + spawn the synth.
194
+ } else {
195
+ appendSkipLog(dir, sid);
196
+ return {};
197
+ }
198
+ } else {
199
+ throw err; // any OTHER claim fault → fail-open via the outer catch (not a dedup → no skip row)
200
+ }
201
+ }
79
202
  }
80
203
 
81
204
  // Stash the payload as .pending (the synth reads it for transcript_path) and raise the handoff gate.
@@ -93,6 +93,38 @@ function stampStartHead(root, sessionId, opts = {}) {
93
93
  }
94
94
  }
95
95
 
96
+ // ── re-arm the SessionEnd synth on resume (#104) ─────────────────────────────────
97
+ // The SessionEnd spawn hook (memory-synth-spawn.cjs) dedups via a PERSISTENT per-session marker
98
+ // .wrxn/continuity/.spawned-<sid>. But SessionEnd fires once per PROCESS instance, not per session id — a
99
+ // resume reopens the SAME id in a NEW process, so the resumed session's later (content-rich) end hits the
100
+ // existing marker, is swallowed fail-open, and never re-synths: the baton FREEZES at the first end. The
101
+ // fix: SessionStart releases (deletes) the marker on EVERY start, so the next SessionEnd for this id is free
102
+ // to synth again. A fresh id (startup / clear) has no marker → no-op. The spawn hook's WITHIN-instance `wx`
103
+ // claim (the same-process #45 guard) is untouched — only this cross-process persistent marker is released
104
+ // between sessions. The marker name is keyed by safeId, byte-identical to the spawn hook's writer, so the
105
+ // release deletes EXACTLY what that hook wrote.
106
+
107
+ const CONTINUITY_DIR_REL = ['.wrxn', 'continuity'];
108
+
109
+ /**
110
+ * Release (delete) the session's once-per-session synth claim marker at
111
+ * <root>/.wrxn/continuity/.spawned-<safeId(sessionId)>, re-arming the SessionEnd synth for a resumed
112
+ * session id (#104). Returns true when a marker was removed; false on any fail-open path (no root/session,
113
+ * absent marker, unwritable / any fault). NEVER throws — orientation must never block on the release.
114
+ * @param {string} root install root
115
+ * @param {string} sessionId the session id (marker key)
116
+ * @returns {boolean}
117
+ */
118
+ function releaseSpawnClaim(root, sessionId) {
119
+ try {
120
+ if (!root || !sessionId) return false;
121
+ fs.unlinkSync(path.join(root, ...CONTINUITY_DIR_REL, `.spawned-${safeId(sessionId)}`));
122
+ return true;
123
+ } catch {
124
+ return false; // absent marker (no-op) / unwritable / any delete fault → fail-open, never blocks orientation
125
+ }
126
+ }
127
+
96
128
  // Walk up from CLAUDE_PROJECT_DIR (or cwd) to the install root carrying wrxn.install.json.
97
129
  function findInstallRoot(startDir) {
98
130
  let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -348,6 +380,15 @@ function main() {
348
380
  /* never block orientation on the baseline stamp */
349
381
  }
350
382
 
383
+ // #104: release the SessionEnd synth's once-per-session claim so a RESUMED session (same id, new process)
384
+ // re-synths its later, content-rich end instead of freezing the baton at the first end. A fresh id
385
+ // (startup / clear) has no marker → no-op. Fail-open: any fault is swallowed and orientation proceeds.
386
+ try {
387
+ if (event && event.session_id) releaseSpawnClaim(root, event.session_id);
388
+ } catch {
389
+ /* never block orientation on the claim release */
390
+ }
391
+
351
392
  // Hold for an in-flight SessionEnd synth (auto-memory-03) so a back-to-back /clear resumes on the
352
393
  // FRESH baton, bounded by the crash safety-cap. Fail-open: any fault here must not block orientation.
353
394
  try {
@@ -399,4 +440,4 @@ if (require.main === module) {
399
440
  }
400
441
  }
401
442
 
402
- module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
443
+ module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, releaseSpawnClaim, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };