@gcunharodrigues/wrxn 0.5.0 → 0.6.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
@@ -23,6 +23,11 @@
23
23
  "class": "managed",
24
24
  "profile": "project"
25
25
  },
26
+ {
27
+ "path": ".claude/hooks/drift-detect.cjs",
28
+ "class": "managed",
29
+ "profile": "project"
30
+ },
26
31
  {
27
32
  "path": ".claude/hooks/enforce-managed-guard.cjs",
28
33
  "class": "managed",
@@ -293,6 +298,11 @@
293
298
  "class": "managed",
294
299
  "profile": "project"
295
300
  },
301
+ {
302
+ "path": ".claude/skills/sync/SKILL.md",
303
+ "class": "managed",
304
+ "profile": "project"
305
+ },
296
306
  {
297
307
  "path": ".claude/skills/tdd/SKILL.md",
298
308
  "class": "managed",
@@ -423,6 +433,11 @@
423
433
  "class": "state",
424
434
  "profile": "project"
425
435
  },
436
+ {
437
+ "path": ".wrxn/sync.cjs",
438
+ "class": "managed",
439
+ "profile": "project"
440
+ },
426
441
  {
427
442
  "path": ".wrxn/wiki.cjs",
428
443
  "class": "managed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.5.0",
3
+ "version": "0.6.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"
@@ -13,7 +13,7 @@
13
13
  "manifest.json"
14
14
  ],
15
15
  "scripts": {
16
- "test": "node --test"
16
+ "test": "node --test --require ./test/setup.cjs"
17
17
  },
18
18
  "dependencies": {
19
19
  "recon-wrxn": "6.0.0-wrxn.3"
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN drift-detect hook — reactive provenance-drift nudge (sync-07).
5
+ // PostToolUse (Edit|Write). When an edit touches a SOURCE file that downstream wiki docs declare
6
+ // `derived_from:`, it injects a <drift> nudge naming the affected doc(s) — so drift surfaces the moment
7
+ // the source moves, not only at the next batch `wrxn sync`.
8
+ //
9
+ // Self-contained: ships into installs, MUST NOT import the kernel lib OR recon (node stdlib only).
10
+ // Mechanical: a pure fs + string frontmatter scan, NO LLM call. Independent of sync-01 — it never reads
11
+ // a `synced_to:` watermark and never writes; detection NUDGE only.
12
+ //
13
+ // Fail-open: any fault (no install root, unreadable wiki, a corrupt page, a missing dir) emits {} and
14
+ // NEVER blocks the edit.
15
+ //
16
+ // Contract: PostToolUse event JSON on stdin → envelope JSON on stdout (exit 0).
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ function emit(envelope) {
22
+ process.stdout.write(JSON.stringify(envelope));
23
+ process.exit(0);
24
+ }
25
+
26
+ function findInstallRoot(startDir) {
27
+ let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
28
+ for (let i = 0; i < 12; i++) {
29
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
30
+ const up = path.dirname(dir);
31
+ if (up === dir) break;
32
+ dir = up;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // Normalize a path-ish value to an install-root-relative POSIX path. Drops a `#symbol` anchor
38
+ // (sync's `derived_from: path#symbol` form), then resolves relative/absolute/`./`-prefixed forms to
39
+ // the same canonical key so same-path and relative-path declarations both match. Returns '' on empty.
40
+ function relTo(root, p) {
41
+ const s = String(p == null ? '' : p).split('#')[0].trim();
42
+ if (!s) return '';
43
+ const abs = path.isAbsolute(s) ? s : path.resolve(root, s);
44
+ return path.relative(root, abs).split(path.sep).join('/');
45
+ }
46
+
47
+ function unquote(s) {
48
+ return String(s).trim().replace(/^["']|["']$/g, '').trim();
49
+ }
50
+
51
+ // Extract the frontmatter block (between the leading `---` fence and the next `---`). A page without a
52
+ // closed fence yields '' — its provenance is unreadable, so it simply contributes no declaration.
53
+ function frontmatter(content) {
54
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
55
+ return m ? m[1] : '';
56
+ }
57
+
58
+ // Parse the `derived_from:` declaration(s) from a page's frontmatter. Handles a scalar, an inline list
59
+ // (`[a, b]`), and a block list (`- item` lines). Returns the raw value strings (anchors intact).
60
+ function parseDerivedFrom(content) {
61
+ const fm = frontmatter(content);
62
+ if (!fm) return [];
63
+ const lines = fm.split(/\r?\n/);
64
+ const out = [];
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const m = /^derived_from:\s*(.*)$/.exec(lines[i]);
67
+ if (!m) continue;
68
+ const val = m[1].trim();
69
+ if (val.startsWith('[')) {
70
+ // inline list: [a, b, c]
71
+ for (const part of val.replace(/^\[|\]$/g, '').split(',')) {
72
+ const v = unquote(part);
73
+ if (v) out.push(v);
74
+ }
75
+ } else if (val) {
76
+ out.push(unquote(val));
77
+ } else {
78
+ // block list: subsequent ` - item` lines until the first non-item line
79
+ for (let j = i + 1; j < lines.length; j++) {
80
+ const li = /^\s*-\s+(.*)$/.exec(lines[j]);
81
+ if (!li) break;
82
+ const v = unquote(li[1]);
83
+ if (v) out.push(v);
84
+ }
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // Collect every .md page under <root>/.wrxn/wiki/ (recursively). A missing/unreadable dir yields [].
91
+ function collectDocs(wikiDir, acc) {
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(wikiDir, { withFileTypes: true });
95
+ } catch {
96
+ return acc; // missing/unreadable tree → no docs
97
+ }
98
+ for (const e of entries) {
99
+ const full = path.join(wikiDir, e.name);
100
+ if (e.isDirectory()) collectDocs(full, acc);
101
+ else if (e.isFile() && e.name.endsWith('.md')) acc.push(full);
102
+ }
103
+ return acc;
104
+ }
105
+
106
+ // The set of doc relpaths whose frontmatter declares `derived_from:` the edited path.
107
+ function affectedDocs(root, editedRel) {
108
+ const wikiDir = path.join(root, '.wrxn', 'wiki');
109
+ const hits = new Set();
110
+ for (const file of collectDocs(wikiDir, [])) {
111
+ let content;
112
+ try {
113
+ content = fs.readFileSync(file, 'utf8');
114
+ } catch {
115
+ continue; // skip an unreadable page (fail-open per-file)
116
+ }
117
+ const docRel = path.relative(root, file).split(path.sep).join('/');
118
+ if (docRel === editedRel) continue; // never flag the edited file against itself
119
+ for (const raw of parseDerivedFrom(content)) {
120
+ if (relTo(root, raw) === editedRel) {
121
+ hits.add(docRel);
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ return [...hits].sort();
127
+ }
128
+
129
+ function main() {
130
+ let event = {};
131
+ try {
132
+ const stdin = fs.readFileSync(0, 'utf8');
133
+ if (stdin.trim()) event = JSON.parse(stdin);
134
+ } catch {
135
+ emit({});
136
+ }
137
+
138
+ const root = findInstallRoot();
139
+ if (!root) emit({});
140
+
141
+ const filePath = event.tool_input && event.tool_input.file_path;
142
+ if (!filePath || typeof filePath !== 'string') emit({}); // not a file-touching tool
143
+
144
+ const editedRel = relTo(root, filePath);
145
+ if (!editedRel) emit({});
146
+
147
+ const docs = affectedDocs(root, editedRel);
148
+ if (docs.length === 0) emit({}); // no downstream provenance → silent
149
+
150
+ const ctx = [
151
+ '<drift>',
152
+ `Edited ${editedRel} — ${docs.length} downstream doc(s) declare derived_from it and may now be stale:`,
153
+ ...docs.map((d) => ` - ${d}`),
154
+ 'Re-derive the affected doc(s), or run `wrxn sync` to confirm the drift set.',
155
+ '</drift>',
156
+ ].join('\n');
157
+
158
+ emit({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: ctx } });
159
+ }
160
+
161
+ try {
162
+ main();
163
+ } catch {
164
+ emit({});
165
+ }
@@ -45,7 +45,8 @@
45
45
  {
46
46
  "matcher": "Edit|Write",
47
47
  "hooks": [
48
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/code-intel-push.cjs\"" }
48
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/code-intel-push.cjs\"" },
49
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/drift-detect.cjs\"" }
49
50
  ]
50
51
  }
51
52
  ],
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: sync
3
+ description: Report which derived docs have drifted from the source code they describe — query recon's computable drift set over the warm serve door and list each stale page, the source symbol that moved, and the watermark it was last reconciled at. Use when someone says "sync", "check for stale docs", "what docs have drifted", or wants to know if the prose is still reconciled with the code before a handoff or release.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # sync — drift report
8
+
9
+ `sync` tells you which **derived prose** has fallen out of step with the **source code** it documents.
10
+ A page that declares `derived_from: path#symbol` carries a `synced_to:` watermark — the source version
11
+ it was last reconciled against. recon-wrxn computes, purely from its index, the set of docs whose source
12
+ symbol has since changed (an AST fingerprint, so reformatting and comment edits do **not** trip drift).
13
+ This skill **queries that set and reports it**. It is the third maintenance loop alongside `dream`
14
+ (memory) — `sync` keeps derived prose reconciled with source.
15
+
16
+ > The **report** is read-only — it never edits a doc or advances a watermark. For **prose** drift you can
17
+ > then go one step further: `sync` can DRAFT a reconciling edit and, on your explicit confirm, write it in
18
+ > place and advance the watermark (the `propose → confirm` loop below). Auto-regen of *mechanical* derived
19
+ > files is still a separate, later step.
20
+
21
+ ## Indirection contract (MUST)
22
+
23
+ > Drive the adapter. NEVER hand-compute drift, re-read a doc's frontmatter, or write any file.
24
+
25
+ - The drift query goes through **`.wrxn/sync.cjs report`** (the install-local door client + report gate).
26
+ - The adapter consumes the `synced_to` watermark **from the recon_drift door response** — recon parsed
27
+ it out of the doc's frontmatter. Do not open wiki files to re-derive it; `sync` is strictly read-only.
28
+
29
+ ## The loop
30
+
31
+ 1. **Run the report** from inside the install (the adapter walks up to `wrxn.install.json` — no `--root`
32
+ needed):
33
+
34
+ ```bash
35
+ node .wrxn/sync.cjs report
36
+ ```
37
+
38
+ 2. **Read the JSON** it prints — `{ status, stale[], unwatermarked[] }`:
39
+
40
+ - **`status: "synced"`** — the stale set is empty. **Say so briefly ("all synced") and stop.** Do not
41
+ manufacture findings. A clean tree is a successful no-op.
42
+ - **`status: "drift"`** — present each `stale[]` entry to the operator: the **doc** page, the **symbol**
43
+ that moved, and **`synced_to` → `current`** (the watermark vs the source's current fingerprint). If
44
+ `unwatermarked[]` is non-empty, note those separately — docs that declare `derived_from` but were
45
+ never watermarked (so drift can't yet be computed for them).
46
+ - **`status: "unavailable"`** — recon's serve door is not warm (no `recon-wrxn serve` running, or it was
47
+ unreachable). Report "drift unavailable — start `recon-wrxn serve` and retry." Never treat this as
48
+ "all synced": unknown is not clean.
49
+
50
+ 3. **Decide per stale doc.** Hand the stale list to the operator. For a **prose** page, you may reconcile
51
+ it with the `propose → confirm` loop below. Regenerating a *mechanical* derived file is still out of
52
+ scope here.
53
+
54
+ ## Propose → confirm (prose re-stamp, sync-06)
55
+
56
+ For a stale PROSE doc, reconcile it WITHOUT ever auto-rewriting words: you draft the edit, the operator
57
+ confirms, then the watermark advances. The watermark means **"verified fresh"**, never "stamped without
58
+ checking". Same split as `dream`: **you (the skill) draft the prose; `.wrxn/sync.cjs` gates and writes.**
59
+
60
+ 1. **Draft + propose (stage).** From a `stale[]` entry, write the reconciling markdown body and stage it
61
+ by-reference — secret-scanned, recorded under `.wrxn/sync/staged.jsonl`, the live doc untouched:
62
+
63
+ ```bash
64
+ node .wrxn/sync.cjs propose proposal.json
65
+ ```
66
+
67
+ `proposal.json` carries the drift record's own fields (do NOT re-derive them) plus your drafted body:
68
+
69
+ ```json
70
+ { "doc": ".wrxn/wiki/concepts/auth-flow.md", "symbol": "src/auth.ts#login",
71
+ "synced_to": "<old watermark from the report>", "current": "<current fingerprint from the report>",
72
+ "body": "# Auth flow\n\n…the reconciled prose…" }
73
+ ```
74
+
75
+ 2. **Present it to the operator and wait.** Show the drafted edit. Nothing is written and the watermark is
76
+ NOT advanced until the operator confirms — staging alone never re-stamps.
77
+
78
+ 3. **Confirm (commit) or decline.** On approval, confirm BY REFERENCE (the doc path). The adapter re-reads
79
+ the staged edit, re-runs the secret-scan + an integrity check (a tampered or altered proposal cannot
80
+ write), edits the doc in place, and advances `synced_to:` to `current`:
81
+
82
+ ```bash
83
+ node .wrxn/sync.cjs confirm approved.json
84
+ ```
85
+
86
+ where `approved.json` is the operator-approved doc list — `[".wrxn/wiki/concepts/auth-flow.md"]` or
87
+ `{ "approved": [".wrxn/wiki/concepts/auth-flow.md"] }`. **Decline** = confirm an empty approval
88
+ (`{ "approved": [] }`) — the file AND the watermark stay exactly as they were.
89
+
90
+ ## Boundaries
91
+
92
+ - **Report is read-only.** Prose `propose → confirm` is the ONLY write path, and only on explicit operator
93
+ confirm. Regen of mechanical derived files is a later sync slice.
94
+ - **Never auto-rewrite words.** The reconciling edit is staged and presented; the in-place write + watermark
95
+ advance happen only on confirm, re-validated at the write boundary.
96
+ - **Declared provenance only.** Only docs carrying a `derived_from:` anchor participate; an undocumented
97
+ file is never "drifted". This is opt-in by provenance, by design.
98
+ - **Fail-soft, never alarmist.** If recon is unreachable the answer is "unavailable", not "stale" and not
99
+ "synced". The adapter never throws; neither should your report.
100
+
101
+ ## Source
102
+
103
+ WRXN Kernel issues sync-04 (report) + sync-06 (prose propose → confirm → re-stamp). Adapter: `.wrxn/sync.cjs`.
104
+ Drift signal: recon-wrxn `recon_drift` (sync-03), watermark storage (sync-01) + AST fingerprint (sync-02).
105
+ Door discovery mirrors `recall-surface.cjs`; skill+adapter shape (stage → commit-by-reference, secret-scan)
106
+ mirrors `dream`. PRD `sync-prd`; ADR 0004.
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN sync adapter — the install-local DRIFT REPORT gate (issue sync-04). Sibling to wiki.cjs /
5
+ // dream.cjs. Self-contained: this ships INTO an install and MUST NOT import the kernel lib or recon —
6
+ // node stdlib ONLY (fs / http / path), so it is install-portable.
7
+ //
8
+ // What it does: discovers recon-wrxn's warm serve door (the recall-surface.cjs contract), POSTs the
9
+ // `recon_drift` tool (sync-03 — the pure indexed-graph stale set), and REPORTS which derived docs are
10
+ // stale + the source symbol that moved + the watermark (`synced_to`) vs the current source fingerprint.
11
+ // recon_drift computes the stale set from the watermark it parsed out of each doc's frontmatter
12
+ // (sync-01) — so the kernel consumes the watermark FROM the door response and stays strictly
13
+ // REPORT-ONLY: it never re-reads or rewrites a wiki file. Auto-regen (sync-05) + prose propose/confirm
14
+ // (sync-06) build on this; there are NO writes here.
15
+ //
16
+ // Subcommand:
17
+ // report query recon_drift over the serve door and print the drift summary JSON:
18
+ // { status, stale[], unwatermarked[] }
19
+ // · status "drift" — at least one doc is stale (stale[] names doc/symbol/synced_to/current).
20
+ // · status "synced" — the warm door computed an EMPTY stale set ("all synced"; AC3 no-op,
21
+ // never manufactures stale rows).
22
+ // · status "unavailable" — recon is unreachable OR answered without the structured drift sidecar
23
+ // (no warm door, a timeout, a non-200, a malformed body, or a 200 whose
24
+ // body lacks an affirmative `drift.stale` array — unknown is not clean,
25
+ // sync-09). FAIL-SOFT: reported, NEVER thrown (AC5).
26
+ //
27
+ // Flag: --root <dir> (override the install-root walk-up; mainly for tests).
28
+
29
+ const fs = require('fs');
30
+ const http = require('http');
31
+ const path = require('path');
32
+ const crypto = require('crypto');
33
+
34
+ const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
35
+ const DRIFT_PATH = '/api/tools/recon_drift'; // the recon serve door (sync-03 AC6 added it to DOOR_TOOLS)
36
+ // Operator-invoked (an explicit `wrxn sync`), NOT the per-prompt hot path — so a generous budget, unlike
37
+ // recall-surface's 150ms UserPromptSubmit ceiling. Still bounded so a wedged door can't hang the command.
38
+ const TIMEOUT_MS = 5000;
39
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; // hard cap on an accumulated door response body (anti-flood)
40
+
41
+ // ── sync-06 prose propose → confirm → re-stamp ───────────────────────────────────
42
+ // The two-phase staging trail mirrors dream's (.wrxn/dream/): a non-.md audit area so recon's prose
43
+ // ingestion (which walks all of .wrxn and reads *.md) never recalls a staged-but-unconfirmed edit.
44
+ const SYNC_DIR = ['.wrxn', 'sync'];
45
+ const WIKI_REL = ['.wrxn', 'wiki']; // prose docs live here; a reconciling edit may target ONLY this subtree.
46
+ const STAGED_FILE = 'staged.jsonl'; // the proposed-but-unconfirmed reconciling edits (full body, by-reference).
47
+ const AUDIT_FILE = 'audit.jsonl'; // append-only outcome log (propose + confirm events).
48
+ const BODY_MAX = 32000; // size cap (chars) — a durable reconciling edit, not a transcript dump (dream parity).
49
+
50
+ // ── install-root resolution (mirrors wiki.cjs / dream.cjs / recall-surface.cjs) ─
51
+ function findInstallRoot(start) {
52
+ let dir = start || process.env.CLAUDE_PROJECT_DIR || process.cwd();
53
+ for (let i = 0; i < 12; i++) {
54
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
55
+ const up = path.dirname(dir);
56
+ if (up === dir) break;
57
+ dir = up;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function flag(name) {
63
+ const i = process.argv.indexOf(`--${name}`);
64
+ return i > -1 ? process.argv[i + 1] : undefined;
65
+ }
66
+
67
+ function installRoot() {
68
+ const root = flag('root') || findInstallRoot();
69
+ if (!root) {
70
+ fail('cannot resolve the install root — run inside a wrxn install (no wrxn.install.json found walking up) or pass --root <dir>');
71
+ }
72
+ return root;
73
+ }
74
+
75
+ function fail(msg) {
76
+ process.stderr.write(`sync: ${msg}\n`);
77
+ process.exit(2);
78
+ }
79
+
80
+ function print(obj) {
81
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
82
+ }
83
+
84
+ // ── the report (PURE) ──────────────────────────────────────────────────────────
85
+ // Deterministic over the parsed recon_drift door response { result, drift:{ stale[], unwatermarked[], … } }.
86
+ // Reads the structured `drift` sidecar (sync-08), normalizing each camelCase entry (page→doc, syncedTo→
87
+ // synced_to, keeping symbol/current) so a stale row directly seeds a proposal, + passes the unwatermarked
88
+ // bucket through (sync-03 AC5 — a distinct bucket, never dropped). "all synced" keys off an EMPTY stale
89
+ // array (AC3); it never invents a row. A response lacking an affirmative `drift.stale` array degrades to
90
+ // "unavailable" (unknown is not clean, sync-09), never throws.
91
+ function isEntry(e) {
92
+ return !!e && typeof e === 'object' && !Array.isArray(e);
93
+ }
94
+
95
+ // Normalize a raw recon DriftReport entry (camelCase page/pageFile/symbol/symbolFile/symbolLine/syncedTo/
96
+ // current) into the kernel's external report contract (doc/symbol/synced_to/current) — the shape `propose`
97
+ // seeds from and the report's consumers read. `doc` is the FILE PATH (recon's `pageFile`), not the page
98
+ // title (`page`): `propose`/`confirm` feed `doc` to resolveSafeDoc, which requires a path under .wrxn/wiki/
99
+ // ending in .md — the title would never resolve. The door-only fields (page title/symbolFile/symbolLine)
100
+ // are dropped; an unwatermarked entry (no syncedTo/current) normalizes to { doc, symbol }.
101
+ function normEntry(e) {
102
+ const out = {};
103
+ if (e.pageFile !== undefined) out.doc = e.pageFile;
104
+ if (e.symbol !== undefined) out.symbol = e.symbol;
105
+ if (e.syncedTo !== undefined) out.synced_to = e.syncedTo;
106
+ if (e.current !== undefined) out.current = e.current;
107
+ return out;
108
+ }
109
+
110
+ function summarizeDrift(parsed) {
111
+ const p = isEntry(parsed) ? parsed : {};
112
+ // sync-08/09: the real recon_drift door returns { result:<markdown>, drift:{ stale[], unwatermarked[], … } }.
113
+ // The structured set lives under `drift` (camelCase), NOT top-level. A 200 lacking an affirmative
114
+ // `drift.stale` ARRAY (a markdown-only {result} body, or an older recon) is UNKNOWN, not clean — surface it
115
+ // as unavailable so a contract regression fails loud, never as a confident false "synced" (sync-09).
116
+ const d = p.drift;
117
+ if (!isEntry(d) || !Array.isArray(d.stale)) return unavailable();
118
+ const stale = d.stale.filter(isEntry).map(normEntry);
119
+ const unwatermarked = (Array.isArray(d.unwatermarked) ? d.unwatermarked : []).filter(isEntry).map(normEntry);
120
+ return { status: stale.length ? 'drift' : 'synced', stale, unwatermarked };
121
+ }
122
+
123
+ function unavailable() {
124
+ return { status: 'unavailable', stale: [], unwatermarked: [] };
125
+ }
126
+
127
+ // ── the door (IO shell, injectable transport) — the recall-surface.cjs contract ─
128
+
129
+ // A pid is alive unless process.kill(pid,0) throws ESRCH. EPERM means it exists (owned by another
130
+ // user) — still alive.
131
+ function pidAlive(pid) {
132
+ try {
133
+ process.kill(pid, 0);
134
+ return true;
135
+ } catch (e) {
136
+ return !!e && e.code === 'EPERM';
137
+ }
138
+ }
139
+
140
+ // Refuse a discovery file another user could have planted, or that is group/world-writable — trusting
141
+ // it would let a hostile workspace point the door host/port at an exfil/injection sink. lstat (not stat)
142
+ // so a symlink's OWN ownership/mode is judged. Any fault → not trusted (treated as not-warm).
143
+ function endpointTrusted(file) {
144
+ let st;
145
+ try {
146
+ st = fs.lstatSync(file);
147
+ } catch {
148
+ return false;
149
+ }
150
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return false; // foreign owner
151
+ if ((st.mode & 0o022) !== 0) return false; // group/world-writable
152
+ return true;
153
+ }
154
+
155
+ // Discover the warm serve door from <root>/.recon-wrxn/serve-endpoint.json = {pid,port}. Returns
156
+ // {pid,port} only when the file is well-owned (not planted), present, well-formed, and the pid is
157
+ // alive — else null (not warm). Never throws.
158
+ function discoverEndpoint(root) {
159
+ const file = path.join(root, ENDPOINT_REL);
160
+ if (!endpointTrusted(file)) return null; // absent, foreign-owned, or loose perms → not warm
161
+ let raw;
162
+ try {
163
+ raw = fs.readFileSync(file, 'utf8');
164
+ } catch {
165
+ return null; // absent (race)
166
+ }
167
+ let obj;
168
+ try {
169
+ obj = JSON.parse(raw);
170
+ } catch {
171
+ return null; // malformed
172
+ }
173
+ const pid = Number(obj && obj.pid);
174
+ const port = Number(obj && obj.port);
175
+ if (!Number.isInteger(pid) || pid <= 0) return null;
176
+ if (!Number.isInteger(port) || port <= 0) return null;
177
+ if (!pidAlive(pid)) return null; // dead pid → not warm
178
+ return { pid, port };
179
+ }
180
+
181
+ // Default transport: a real POST over http with a hard timeout. Resolves {statusCode, body}; rejects
182
+ // on socket error or timeout. Injectable so unit tests never touch the network (mirrors recall-surface).
183
+ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
184
+ return new Promise((resolve, reject) => {
185
+ const payload = Buffer.from(JSON.stringify(body));
186
+ const deadline = timeoutMs || TIMEOUT_MS;
187
+ let settled = false;
188
+ let wall = null;
189
+ const done = (fn, arg) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ if (wall) clearTimeout(wall);
193
+ fn(arg);
194
+ };
195
+ const req = http.request(
196
+ {
197
+ host: '127.0.0.1',
198
+ port,
199
+ path: reqPath,
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length },
202
+ },
203
+ (res) => {
204
+ const chunks = [];
205
+ let total = 0;
206
+ res.on('data', (c) => {
207
+ total += c.length;
208
+ if (total > MAX_RESPONSE_BYTES) { req.destroy(new Error('drift door response too large')); return; }
209
+ chunks.push(c);
210
+ });
211
+ res.on('end', () => done(resolve, { statusCode: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
212
+ res.on('error', (e) => done(reject, e));
213
+ }
214
+ );
215
+ req.on('error', (e) => done(reject, e));
216
+ // Idle timeout (no bytes for `deadline`) AND an independent wall-clock that bounds a trickle attacker
217
+ // dribbling bytes to keep the idle timer from ever firing.
218
+ req.setTimeout(deadline, () => req.destroy(new Error('drift door timeout')));
219
+ wall = setTimeout(() => req.destroy(new Error('drift door wall-clock timeout')), deadline);
220
+ req.write(payload);
221
+ req.end();
222
+ });
223
+ }
224
+
225
+ // IO shell: discover the door, POST recon_drift, summarize the stale set. `transport` is injected in
226
+ // tests; production uses httpTransport. FAIL-SOFT everywhere: a cold/dead door, a timeout, a non-200, or
227
+ // a malformed body all degrade to status "unavailable" — never an exception (AC5).
228
+ async function driftFromDoor(root, { transport, timeoutMs } = {}) {
229
+ const door = discoverEndpoint(root);
230
+ if (!door) return unavailable(); // not warm → recon unreachable
231
+ let resp;
232
+ try {
233
+ resp = await (transport || httpTransport)({
234
+ port: door.port,
235
+ path: DRIFT_PATH,
236
+ body: {}, // recon_drift is a whole-graph scan; it takes no required args
237
+ timeoutMs: timeoutMs || TIMEOUT_MS,
238
+ });
239
+ } catch {
240
+ return unavailable(); // timeout / connection refused / abort
241
+ }
242
+ if (!resp || resp.statusCode !== 200) return unavailable();
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(resp.body);
246
+ } catch {
247
+ return unavailable(); // malformed body
248
+ }
249
+ return summarizeDrift(parsed);
250
+ }
251
+
252
+ // ── credential / secret scan (reused from dream.cjs, security M2) ────────────────
253
+ // A reconciling edit must never harden a session secret into prose. Same patterns + scope as dream's
254
+ // secretScan — replicated here because each install-only adapter is self-contained (node stdlib only;
255
+ // no shared kernel-lib import), exactly as findInstallRoot/flag/fail are duplicated across these files.
256
+ // CASE-SENSITIVE: the token shapes are case-specific.
257
+ const SECRET_PATTERNS = [
258
+ /AKIA[0-9A-Z]{16}/, // AWS access key id
259
+ /gh[pousr]_[A-Za-z0-9]{36}/, // GitHub token (ghp_/gho_/ghu_/ghs_/ghr_)
260
+ /npm_[A-Za-z0-9]{36}/, // npm automation token
261
+ /sk-[A-Za-z0-9]{20,}/, // OpenAI-style secret key
262
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private-key header
263
+ ];
264
+
265
+ function secretScan(text) {
266
+ const s = String(text || ''); // NOT lowercased — the token shapes are case-sensitive.
267
+ for (const re of SECRET_PATTERNS) if (re.test(s)) return 'contains_secret';
268
+ return null;
269
+ }
270
+
271
+ // ── watermark fingerprint validation (security M1) ───────────────────────────────
272
+ // `current` is written VERBATIM into the doc's `synced_to:` frontmatter at confirm (restampDoc), so it is
273
+ // a SECOND write channel the body-only secret-scan missed: a newline/colon injects arbitrary
274
+ // frontmatter/markdown, and a credential hardens unscanned into a recall-ingested page (defeats AC4).
275
+ // `synced_to` is operator/LLM-supplied too. Both must be a single fingerprint/commit token; `current` must
276
+ // additionally pass the secret-scan. proposalHash covers `current`, so the integrity gate does NOT catch
277
+ // this. Returns a problem code (the propose fail reason / the confirm skip reason) or null.
278
+ const FINGERPRINT_RE = /^[A-Za-z0-9._-]{1,128}$/;
279
+
280
+ function fingerprintProblem(rec) {
281
+ if (typeof rec.current !== 'string' || !FINGERPRINT_RE.test(rec.current)) return 'malformed_current';
282
+ if (rec.synced_to != null && (typeof rec.synced_to !== 'string' || !FINGERPRINT_RE.test(rec.synced_to))) return 'malformed_synced_to';
283
+ return secretScan(rec.current); // 'contains_secret' (current is a write channel) or null
284
+ }
285
+
286
+ // ── shared helpers (mirror dream.cjs) ────────────────────────────────────────────
287
+ // The first positional after the subcommand (the JSON file path), up to the first --flag.
288
+ function positionalFile() {
289
+ for (let i = 3; i < process.argv.length; i++) {
290
+ if (process.argv[i].startsWith('--')) break;
291
+ return process.argv[i];
292
+ }
293
+ fail('missing <file.json> argument');
294
+ return undefined;
295
+ }
296
+
297
+ function readJson(file) {
298
+ try {
299
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
300
+ } catch (err) {
301
+ fail(`cannot read JSON from "${file}": ${err.message}`);
302
+ return undefined;
303
+ }
304
+ }
305
+
306
+ function appendLine(file, obj) {
307
+ fs.appendFileSync(file, JSON.stringify(obj) + '\n');
308
+ }
309
+
310
+ function syncDir(root) {
311
+ const dir = path.join(root, ...SYNC_DIR);
312
+ fs.mkdirSync(dir, { recursive: true });
313
+ return dir;
314
+ }
315
+
316
+ // ── path safety: a reconciling edit may target ONLY a prose .md under .wrxn/wiki/ ─
317
+ // The proposal's `doc` is LLM/operator-controlled, so it is the write-path's trust boundary (the sync
318
+ // analog of dream's flag-injection guard). Resolve it and refuse anything that escapes the wiki subtree
319
+ // or is not a .md — so confirm can never write outside the curated prose tree. Returns the abs path or null.
320
+ function resolveSafeDoc(root, doc) {
321
+ if (typeof doc !== 'string' || !doc.trim()) return null;
322
+ const wikiRoot = path.join(root, ...WIKI_REL);
323
+ const abs = path.resolve(root, doc);
324
+ const rel = path.relative(wikiRoot, abs);
325
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; // escapes .wrxn/wiki/
326
+ if (!abs.endsWith('.md')) return null; // prose pages only
327
+ return abs;
328
+ }
329
+
330
+ // The integrity fingerprint captured at stage time over the fields that determine the write (doc target,
331
+ // watermark advance target, and body). Recomputed at the write boundary and compared to the staged value
332
+ // → a staged record whose body/target was altered after staging cannot write (AC2 tamper-refusal).
333
+ function proposalHash(p) {
334
+ const canon = JSON.stringify({ doc: String(p.doc || ''), current: String(p.current || ''), body: String(p.body || '') });
335
+ return crypto.createHash('sha256').update(canon).digest('hex');
336
+ }
337
+
338
+ // ── the in-place re-stamp (PURE) — the net-new write transform ───────────────────
339
+ // Replace a prose doc's body with the reconciling `body` and advance its `synced_to:` watermark to the
340
+ // source's `current` fingerprint, preserving all other frontmatter (derived_from etc.). A doc without a
341
+ // frontmatter fence cannot carry a watermark → returns null (the caller skips it, never writes). This is
342
+ // the OPPOSITE of dream's create-refuses-overwrite: an in-place edit + re-stamp of an existing prose page.
343
+ function restampDoc(content, { body, current }) {
344
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(content));
345
+ if (!m) return null; // no frontmatter fence → not a watermarkable prose doc
346
+ const lines = m[1].split(/\r?\n/);
347
+ let found = false;
348
+ for (let i = 0; i < lines.length; i++) {
349
+ if (/^synced_to:\s*/.test(lines[i])) { lines[i] = `synced_to: ${current}`; found = true; break; }
350
+ }
351
+ if (!found) lines.push(`synced_to: ${current}`); // unwatermarked doc → first stamp
352
+ return ['---', lines.join('\n'), '---', '', body, ''].join('\n');
353
+ }
354
+
355
+ // Read .wrxn/sync/staged.jsonl into a doc → staged-record map (last proposed wins). Malformed lines skip.
356
+ function readStaged(root) {
357
+ const map = new Map();
358
+ let txt;
359
+ try {
360
+ txt = fs.readFileSync(path.join(root, ...SYNC_DIR, STAGED_FILE), 'utf8');
361
+ } catch {
362
+ return map; // no staging trail yet → nothing to confirm by reference
363
+ }
364
+ for (const line of txt.split('\n')) {
365
+ const s = line.trim();
366
+ if (!s) continue;
367
+ try {
368
+ const rec = JSON.parse(s);
369
+ if (rec && rec.doc && typeof rec.body === 'string') map.set(rec.doc, rec);
370
+ } catch {
371
+ /* skip a malformed staging line */
372
+ }
373
+ }
374
+ return map;
375
+ }
376
+
377
+ // Normalize confirm input into the operator-approved DOC list (["doc"…] or { approved:[…] }). An empty
378
+ // list is the DECLINE — confirm writes nothing (AC3).
379
+ function approvedDocs(input) {
380
+ if (Array.isArray(input)) return input.map(String);
381
+ if (input && typeof input === 'object' && Array.isArray(input.approved)) return input.approved.map(String);
382
+ return [];
383
+ }
384
+
385
+ // ── subcommands ─────────────────────────────────────────────────────────────────
386
+ // propose (STAGE): secret-scan the drafted reconciling edit, then record it by-reference under
387
+ // .wrxn/sync/staged.jsonl with an integrity fingerprint. Never touches the live doc. Mirrors dream's stage.
388
+ function runPropose() {
389
+ const input = readJson(positionalFile());
390
+ const root = installRoot();
391
+ const p = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
392
+ if (!resolveSafeDoc(root, p.doc)) fail('propose needs a "doc" path inside .wrxn/wiki/ ending in .md');
393
+ if (typeof p.body !== 'string' || !p.body.trim()) fail('propose needs a non-empty "body" — the reconciling edit the skill drafted');
394
+ if (p.body.length > BODY_MAX) fail(`propose rejected — body exceeds the ${BODY_MAX}-char cap (body_too_large); a durable reconciling edit, not a transcript dump`); // L3: dream parity
395
+ if (typeof p.current !== 'string' || !p.current.trim()) {
396
+ fail('propose needs "current" — the source fingerprint to advance the watermark to (from the drift report, never re-derived here)');
397
+ }
398
+ const sec = secretScan(p.body); // AC4: secret-scan BEFORE staging
399
+ if (sec) fail(`propose rejected — the drafted edit contains a credential (${sec}); never reconcile a session secret into prose`);
400
+ // M1: `current` becomes the doc's synced_to watermark verbatim at confirm — a second write channel the
401
+ // body-only scan missed. Shape-validate current + synced_to (no newline/colon → no frontmatter injection)
402
+ // and secret-scan current, at propose AND again at the confirm write boundary.
403
+ const fp = fingerprintProblem(p);
404
+ if (fp === 'contains_secret') fail('propose rejected — "current" contains a credential; never write a session secret into the doc watermark');
405
+ if (fp) fail(`propose rejected — "${fp === 'malformed_synced_to' ? 'synced_to' : 'current'}" must be a fingerprint token matching ${FINGERPRINT_RE} (no newline/colon) — it is written verbatim into the doc watermark`);
406
+
407
+ const dir = syncDir(root);
408
+ const ts = new Date().toISOString();
409
+ const record = { ts, op: 'propose', doc: p.doc, symbol: p.symbol, synced_to: p.synced_to, current: p.current, body: p.body, hash: proposalHash(p) };
410
+ appendLine(path.join(dir, STAGED_FILE), record);
411
+ appendLine(path.join(dir, AUDIT_FILE), { ts, op: 'propose', doc: p.doc, current: p.current });
412
+ return print({ staged: 1, doc: p.doc, stagedFile: path.relative(root, path.join(dir, STAGED_FILE)) });
413
+ }
414
+
415
+ // confirm (COMMIT-by-reference): for each operator-approved doc, look up its staged edit, RE-VALIDATE at
416
+ // the write boundary (secret-scan → integrity fingerprint → path-safety → target exists → frontmatter),
417
+ // then edit the file in place + advance its watermark. A rejected/tampered/declined edit cannot write
418
+ // (AC2/AC3); the watermark advances ONLY as part of a confirmed write (AC5). Binds written == staged.
419
+ function runConfirm() {
420
+ const input = readJson(positionalFile());
421
+ const root = installRoot();
422
+ const approved = approvedDocs(input);
423
+ const staged = readStaged(root);
424
+ const written = [];
425
+ const skipped = [];
426
+ for (const ref of approved) {
427
+ const key = String(ref);
428
+ const rec = staged.get(key);
429
+ if (!rec) { skipped.push({ doc: key, reason: 'not_staged' }); continue; }
430
+ const sec = secretScan(rec.body); // re-scan at the write boundary
431
+ if (sec) { skipped.push({ doc: key, reason: sec }); continue; }
432
+ // M1: re-gate the `current` write channel at the boundary too (a seeded staged.jsonl bypasses propose).
433
+ const fp = fingerprintProblem(rec);
434
+ if (fp) { skipped.push({ doc: key, reason: fp }); continue; }
435
+ if (proposalHash(rec) !== rec.hash) { skipped.push({ doc: key, reason: 'integrity_mismatch' }); continue; } // tamper → refuse
436
+ const abs = resolveSafeDoc(root, rec.doc);
437
+ if (!abs) { skipped.push({ doc: key, reason: 'unsafe_target' }); continue; }
438
+ // L2: resolveSafeDoc is lexical — refuse a pre-existing symlink so the read+write can't FOLLOW it out of
439
+ // the wiki subtree (git preserves symlinks; a hostile branch/clone can plant one). lstat → the link's
440
+ // own type; fail closed (skip, no write), mirroring unsafe_target / missing_target.
441
+ let lst;
442
+ try {
443
+ lst = fs.lstatSync(abs);
444
+ } catch {
445
+ skipped.push({ doc: key, reason: 'missing_target' }); continue; // the doc vanished since staging
446
+ }
447
+ if (lst.isSymbolicLink()) { skipped.push({ doc: key, reason: 'symlink_target' }); continue; }
448
+ let content;
449
+ try {
450
+ content = fs.readFileSync(abs, 'utf8');
451
+ } catch {
452
+ skipped.push({ doc: key, reason: 'missing_target' }); continue; // the doc vanished since staging
453
+ }
454
+ const next = restampDoc(content, { body: rec.body, current: rec.current });
455
+ if (next == null) { skipped.push({ doc: key, reason: 'no_frontmatter' }); continue; }
456
+ fs.writeFileSync(abs, next); // the in-place edit + re-stamp (the net-new write path)
457
+ written.push({ doc: rec.doc, synced_to: rec.current });
458
+ }
459
+ appendLine(path.join(syncDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'confirm', written: written.map((w) => w.doc), skipped });
460
+ return print({ written, skipped });
461
+ }
462
+
463
+ async function runReport() {
464
+ const root = installRoot();
465
+ let summary;
466
+ try {
467
+ summary = await driftFromDoor(root, {});
468
+ } catch {
469
+ summary = unavailable(); // belt-and-suspenders: the report never throws
470
+ }
471
+ print(summary);
472
+ process.exit(0);
473
+ }
474
+
475
+ async function main() {
476
+ const cmd = process.argv[2];
477
+ switch (cmd) {
478
+ case 'report':
479
+ return runReport();
480
+ case 'propose':
481
+ return runPropose();
482
+ case 'confirm':
483
+ return runConfirm();
484
+ default:
485
+ process.stdout.write('Usage: node .wrxn/sync.cjs <report|propose <proposal.json>|confirm <approved.json>> [--root <dir>]\n');
486
+ process.exit(cmd ? 2 : 0);
487
+ }
488
+ }
489
+
490
+ if (require.main === module) {
491
+ main().catch((err) => fail(err && err.message ? err.message : 'unexpected error'));
492
+ }
493
+
494
+ module.exports = {
495
+ summarizeDrift,
496
+ driftFromDoor,
497
+ discoverEndpoint,
498
+ httpTransport,
499
+ pidAlive,
500
+ findInstallRoot,
501
+ DRIFT_PATH,
502
+ TIMEOUT_MS,
503
+ // sync-06 prose propose → confirm → re-stamp
504
+ secretScan,
505
+ restampDoc,
506
+ proposalHash,
507
+ resolveSafeDoc,
508
+ };
@@ -25,8 +25,10 @@ const path = require('path');
25
25
  // `_slots` (dream-04) holds the durable standing-focus page (`_slots/current-focus.md`) — the LONE
26
26
  // wiki page that may be overwritten in place, and only via `write-page --force`.
27
27
  const TIERS = ['concepts', 'decisions', 'gotchas', 'sessions', '_rules', '_slots'];
28
- // The one tier whose pages `--force` may overwrite — every other tier stays create-only / refuse-overwrite.
28
+ // The one page `--force` may overwrite — `_slots/current-focus.md`, the durable focus slot. Every other
29
+ // page (any other tier, or any other slug in `_slots`) stays create-only / refuse-overwrite.
29
30
  const OVERWRITABLE_TIER = '_slots';
31
+ const OVERWRITABLE_SLUG = 'current-focus';
30
32
 
31
33
  // ── install-root resolution (walk up to the wrxn.install.json receipt) ────────
32
34
  // Mirrors payload/.claude/hooks/enforce-managed-guard.cjs findInstallRoot.
@@ -129,11 +131,12 @@ function runWritePage() {
129
131
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) fail(`slug must be kebab-case ([a-z0-9-]): "${slug}"`);
130
132
 
131
133
  // `--force` is the LONE overwrite-exception (dream-04): it overwrites a page in place, and ONLY for
132
- // the `_slots` focus slot. Every normal write-page (no `--force`, any other tier) still refuses to
133
- // clobber — so the wiki stays additive/curated and only the standing-focus slot may be updated.
134
+ // the single `_slots/current-focus` slot. Every other write-page (no `--force`, any other tier, or any
135
+ // other slug in `_slots`) still refuses to clobber — so the wiki stays additive/curated and only the
136
+ // one standing-focus page may be updated (dream-qa-07: path-scoped, not tier-scoped).
134
137
  const force = process.argv.includes('--force');
135
- if (force && tier !== OVERWRITABLE_TIER) {
136
- fail(`--force overwrite is only permitted for the ${OVERWRITABLE_TIER} focus slot (the lone update-exception), not "${tier}"`);
138
+ if (force && (tier !== OVERWRITABLE_TIER || slug !== OVERWRITABLE_SLUG)) {
139
+ fail(`--force overwrite is only permitted for the ${OVERWRITABLE_TIER}/${OVERWRITABLE_SLUG} focus slot (the lone update-exception), not "${tier}/${slug}"`);
137
140
  }
138
141
 
139
142
  const root = wikiRoot();