@gcunharodrigues/wrxn 0.10.0 → 0.12.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.
Files changed (46) hide show
  1. package/bin/wrxn.cjs +155 -0
  2. package/lib/ci-checks.cjs +296 -0
  3. package/lib/executor.cjs +7 -4
  4. package/lib/install.cjs +3 -0
  5. package/lib/protect.cjs +195 -0
  6. package/lib/release.cjs +51 -0
  7. package/lib/ship.cjs +76 -0
  8. package/lib/update.cjs +15 -1
  9. package/manifest.json +17 -12
  10. package/migrations/005-protect-main-gate.cjs +32 -0
  11. package/migrations/006-refresh-routing-rule.cjs +56 -0
  12. package/migrations/007-auto-memory-transition.cjs +136 -0
  13. package/package.json +1 -1
  14. package/payload/.claude/agents/builder.md +2 -1
  15. package/payload/.claude/agents/devops.md +34 -29
  16. package/payload/.claude/agents/qa-walker.md +2 -1
  17. package/payload/.claude/agents/researcher.md +2 -1
  18. package/payload/.claude/agents/reviewer.md +2 -1
  19. package/payload/.claude/agents/security.md +2 -1
  20. package/payload/.claude/constitution.md +10 -6
  21. package/payload/.claude/hooks/enforce-managed-guard.cjs +14 -11
  22. package/payload/.claude/hooks/enforce-managed-precommit.cjs +11 -8
  23. package/payload/.claude/hooks/enforce-pipeline-adherence.cjs +116 -0
  24. package/payload/.claude/hooks/memory-synth-spawn.cjs +101 -0
  25. package/payload/.claude/hooks/session-start.cjs +86 -8
  26. package/payload/.claude/hooks/synapse-engine.cjs +2 -3
  27. package/payload/.claude/settings.json +14 -4
  28. package/payload/.claude/skills/compass/SKILL.md +6 -1
  29. package/payload/.claude/skills/dream/SKILL.md +2 -31
  30. package/payload/.claude/skills/synapse/SKILL.md +9 -9
  31. package/payload/.claude/skills/synapse/references/brackets.md +1 -2
  32. package/payload/.claude/skills/synapse/references/domains.md +2 -2
  33. package/payload/.claude/skills/synapse/references/layers.md +4 -5
  34. package/payload/.github/workflows/wrxn-ci.yml +54 -0
  35. package/payload/.synapse/global +2 -2
  36. package/payload/.synapse/manifest +4 -3
  37. package/payload/.synapse/pipeline +1 -0
  38. package/payload/.synapse/routing +1 -1
  39. package/payload/.wrxn/dream.cjs +112 -66
  40. package/payload/.wrxn/memory-synth.cjs +761 -0
  41. package/payload/.wrxn/memory.config.json +12 -0
  42. package/payload/.wrxn/wiki.cjs +7 -19
  43. package/payload/.claude/hooks/enforce-push-authority.cjs +0 -52
  44. package/payload/.claude/hooks/enforce-review-marker.cjs +0 -62
  45. package/payload/.claude/hooks/enforce-tests-on-push.cjs +0 -40
  46. package/payload/.claude/skills/handoff/SKILL.md +0 -22
package/bin/wrxn.cjs CHANGED
@@ -12,11 +12,15 @@ const worktree = require('../lib/worktree.cjs');
12
12
  const executor = require('../lib/executor.cjs');
13
13
  const onboard = require('../lib/onboard.cjs');
14
14
  const connect = require('../lib/connect.cjs');
15
+ const ship = require('../lib/ship.cjs');
16
+ const protect = require('../lib/protect.cjs');
17
+ const release = require('../lib/release.cjs');
15
18
  const brain = require('../lib/brain.cjs');
16
19
  const statusline = require('../lib/statusline.cjs');
17
20
  const { convert } = require('../lib/convert.cjs');
18
21
  const { ingest } = require('../lib/ingest.cjs');
19
22
  const { flowStatus } = require('../lib/flow-status.cjs');
23
+ const ciChecks = require('../lib/ci-checks.cjs');
20
24
 
21
25
  const PKG_ROOT = path.join(__dirname, '..');
22
26
 
@@ -47,6 +51,14 @@ function parseArgs(argv) {
47
51
  args.flags.root = argv[++i];
48
52
  } else if (a === '--base') {
49
53
  args.flags.base = argv[++i];
54
+ } else if (a === '--range') {
55
+ args.flags.range = argv[++i];
56
+ } else if (a === '--branch') {
57
+ args.flags.branch = argv[++i];
58
+ } else if (a === '--title') {
59
+ args.flags.title = argv[++i];
60
+ } else if (a === '--body') {
61
+ args.flags.body = argv[++i];
50
62
  } else if (a === '--path') {
51
63
  args.flags.path = argv[++i];
52
64
  } else if (a === '--executor') {
@@ -122,6 +134,22 @@ Usage:
122
134
  list print all registered connections (agent-readable JSON)
123
135
  get <name> print one connection by name
124
136
 
137
+ wrxn ship --title "<pr title>" [--branch <name>] [--base <main>] [--body <text>] [--dry-run] [--root <dir>]
138
+ the autonomous promote path: push the reviewed branch, open a PR,
139
+ and arm auto-merge (gh pr merge --auto --squash) — GitHub merges the
140
+ instant the server-enforced CI gate is green. --branch defaults to
141
+ the repo's current branch. --dry-run prints the promote plan without
142
+ running it (a non-destructive preview). Stops at the first failing
143
+ step (a failed push never opens a PR).
144
+
145
+ wrxn protect [--root <dir>] apply the wrxn-main-gate server-side ruleset to this repo's origin —
146
+ the hard gate that replaces the settings.local.json env-flag dance:
147
+ block direct push to the default branch, require a PR + the wrxn-ci
148
+ check, require the branch up to date, no bypass actor. Idempotent
149
+ (create-or-update by name) and fail-soft (no gh / not admin / no remote
150
+ → a clear message, exit 0). Repo-agnostic: the slug is read from origin,
151
+ so the SAME command protects the kernel, any install, and recon-wrxn.
152
+
125
153
  wrxn brain query "<q>" [--json] [--limit <n>] [--type <prose|code|NodeType>] [--neighbors] [--root <dir>]
126
154
  ask the warm Brain (recon-wrxn's code+prose graph) from the terminal.
127
155
  WHOLE-BRAIN by default. Discovers the live serve door via
@@ -158,6 +186,23 @@ Usage:
158
186
  aios-intake.md (the deterministic half of the onboard skill;
159
187
  workspace installs only). Idempotent.
160
188
 
189
+ wrxn ci [--root <dir>] run the universal CI checks over an install (the kernel-universal
190
+ half of the wrxn-ci gate): managed-file integrity (managed files not
191
+ drifted from kernel source), wiki-lint, synapse-manifest lint, JSON
192
+ validity, and node --check syntax over the shipped .cjs. Exits non-zero
193
+ on any failure. The wrxn-ci workflow runs this after the project's own
194
+ WRXN_TEST_CMD, so CI is never a vacuous pass — even a no-suite repo is
195
+ really gated.
196
+
197
+ wrxn release-check [--range <before>..<head>] [--root <dir>]
198
+ the CD type-gate: read the merged commit messages from the git range
199
+ (default: the HEAD commit) and decide by conventional-commit type
200
+ whether the merge publishes — feat→minor, fix/perf→patch, breaking
201
+ (type! / BREAKING CHANGE:)→major; chore/docs/refactor/test → no release.
202
+ Prints {"release":bool,"bump":...} and, in CI, appends release/bump to
203
+ $GITHUB_OUTPUT. The release-on-merge workflow gates the OIDC publish on
204
+ it. Fail-safe: a non-repo / bad range → no release.
205
+
161
206
  wrxn flow status <prd> [--root <dir>]
162
207
  print the flow-status board for a PRD's issue set. Reads issues
163
208
  from .scratch/<prd>/issues/, detects gate artifacts (green commit
@@ -243,6 +288,14 @@ async function main(argv) {
243
288
  if (report.migrationsRan && report.migrationsRan.length) {
244
289
  process.stdout.write(`migrations applied: ${report.migrationsRan.join(', ')}\n`);
245
290
  }
291
+ // Surface the server-side gate outcome (gate-02 MED-1). A soft-skip on the PRIMARY delivery path
292
+ // must NOT be silent — a silent skip is the very "no-op gate believed active" defect this epic kills.
293
+ // Mirrors `wrxn protect`; fail-soft is preserved (this only prints, never throws, still exit 0).
294
+ if (report.protection) {
295
+ process.stdout.write(report.protection.ok
296
+ ? `protection: ${report.protection.detail}\n`
297
+ : `protection skipped: ${report.protection.reason}\n`);
298
+ }
246
299
  return 0;
247
300
  }
248
301
 
@@ -440,6 +493,61 @@ async function main(argv) {
440
493
  }
441
494
  }
442
495
 
496
+ if (cmd === 'ship') {
497
+ // The autonomous promote path: push the reviewed branch, open a PR, arm auto-merge — GitHub
498
+ // merges the instant the server-enforced CI gate is green — no env flag, no GitHub clicks. Real
499
+ // git/gh run via the
500
+ // real spawnSync invoker (ship.defaultInvoke) — that is what makes the promote "validated by
501
+ // invocation". --dry-run prints the plan without running it (a non-destructive preview).
502
+ const root = path.resolve(args.flags.root || process.cwd());
503
+ let branch = args.flags.branch;
504
+ if (!branch) {
505
+ // Default to the repo's current branch — the reviewed branch the devops executor stands on.
506
+ try {
507
+ branch = execFileSync('git', ['-C', root, 'branch', '--show-current'], { encoding: 'utf8' }).trim();
508
+ } catch { branch = ''; }
509
+ }
510
+ const title = args.flags.title;
511
+ const base = args.flags.base || ship.DEFAULT_BASE;
512
+ const body = args.flags.body || '';
513
+ if (!branch) { process.stderr.write('wrxn: ship requires a branch — pass --branch <name>, or run inside the git repo on the branch to promote\n'); return 2; }
514
+ if (!title) { process.stderr.write('wrxn: ship requires --title "<pr title>"\n'); return 2; }
515
+ let plan;
516
+ try {
517
+ plan = ship.buildShipPlan({ branch, title, base, body });
518
+ } catch (err) {
519
+ process.stderr.write(`wrxn: ${err.message}\n`);
520
+ return 2;
521
+ }
522
+ if (args.flags['dry-run']) {
523
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
524
+ return 0;
525
+ }
526
+ const res = ship.ship({ branch, title, base, body }); // real defaultInvoke (git + gh)
527
+ for (const s of res.steps) {
528
+ process.stdout.write(` ${s.ok ? '✓' : '✗'} ${s.step} — ${s.detail}\n`);
529
+ }
530
+ if (res.ok) {
531
+ process.stdout.write(`shipped ${branch} → PR opened on ${base} with auto-merge armed (GitHub merges when CI is green)\n`);
532
+ return 0;
533
+ }
534
+ process.stderr.write(`wrxn: ship failed at "${res.failed}" — promote halted (no PR/auto-merge past the failure)\n`);
535
+ return 2;
536
+ }
537
+
538
+ if (cmd === 'protect') {
539
+ // Apply the wrxn-main-gate server-side ruleset to this repo's origin — the hard gate that replaces
540
+ // the disarmable settings.local.json env-flag dance (ADR 0007). Repo-agnostic: the slug is read from
541
+ // origin (protect.protectOrigin), so the SAME command protects the kernel, any install, and the
542
+ // recon-wrxn sibling. Real git/gh run via protect's defaultInvoke (no injected invoker) — that is
543
+ // what makes the application "validated by invocation". Fail-soft: no gh / not admin / no remote → a
544
+ // clear message and exit 0, so it never breaks a remote-less checkout.
545
+ const root = path.resolve(args.flags.root || process.cwd());
546
+ const res = protect.protectOrigin(root);
547
+ process.stdout.write(res.ok ? `wrxn protect → ${res.detail}\n` : `wrxn protect → skipped: ${res.reason}\n`);
548
+ return 0;
549
+ }
550
+
443
551
  if (cmd === 'brain') {
444
552
  const sub = args._[1];
445
553
  if (sub !== 'query') {
@@ -635,6 +743,53 @@ async function main(argv) {
635
743
  return 2;
636
744
  }
637
745
 
746
+ if (cmd === 'ci') {
747
+ const root = path.resolve(args.flags.root || process.cwd());
748
+ const { ok, results } = ciChecks.runChecks(root, { pkgRoot: PKG_ROOT });
749
+ process.stdout.write(`wrxn-ci (${root})\n`);
750
+ for (const c of results) {
751
+ process.stdout.write(` ${c.ok ? '✓' : '✗'} ${c.name} — ${c.detail}\n`);
752
+ for (const f of c.failures) process.stdout.write(` - ${f}\n`);
753
+ }
754
+ process.stdout.write(ok ? 'wrxn-ci PASS\n' : 'wrxn-ci FAIL\n');
755
+ return ok ? 0 : 2;
756
+ }
757
+
758
+ if (cmd === 'release-check') {
759
+ // The CD type-gate bridge (gate-05): read the merged commit messages from a git range, decide by
760
+ // conventional-commit type whether the merge publishes, and emit the decision. release.yml gates the
761
+ // OIDC publish on it; recon-wrxn (gate-06) reuses the same gate via `npx @gcunharodrigues/wrxn`.
762
+ // The git read lives here (CLI layer); the pure classifier (shouldRelease) is unit-tested.
763
+ const root = path.resolve(args.flags.root || process.cwd());
764
+ const range = args.flags.range;
765
+ let logArgs;
766
+ if (range && range.includes('..')) {
767
+ const [before, head] = range.split('..');
768
+ const headRef = head || 'HEAD';
769
+ // A zero before-sha is GitHub's "new branch" sentinel — there is no prior tip, so the range is
770
+ // meaningless; read only the head commit.
771
+ logArgs = /^0+$/.test(before)
772
+ ? ['-C', root, 'log', '-1', '--format=%B%x00', headRef]
773
+ : ['-C', root, 'log', '--format=%B%x00', range];
774
+ } else {
775
+ logArgs = ['-C', root, 'log', '-1', '--format=%B%x00', range || 'HEAD'];
776
+ }
777
+ let raw = '';
778
+ try {
779
+ raw = execFileSync('git', logArgs, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
780
+ } catch {
781
+ // Not a git repo / bad range — fail SAFE: never publish on uncertainty.
782
+ raw = '';
783
+ }
784
+ const decision = release.shouldRelease(release.parseLog(raw));
785
+ // Expose the decision to the workflow's conditional publish step.
786
+ if (process.env.GITHUB_OUTPUT) {
787
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `release=${decision.release}\nbump=${decision.bump || ''}\n`);
788
+ }
789
+ process.stdout.write(JSON.stringify(decision) + '\n');
790
+ return 0;
791
+ }
792
+
638
793
  process.stderr.write(`wrxn: unknown command "${cmd}"\n\n${USAGE}\n`);
639
794
  return 2;
640
795
  }
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ // WRXN universal CI checks — the pure-node predicates the `wrxn-ci` workflow runs on every PR so CI
4
+ // is never a vacuous `true`, even on a repo with no project suite. Each predicate is a deterministic
5
+ // function over the install tree returning { name, ok, failures: string[], detail }. The thin
6
+ // `wrxn ci` CLI (bin/wrxn.cjs) runs them in the install cwd and exits non-zero on any failure, so the
7
+ // SAME tested logic runs in the kernel and in every install. No side effects, node stdlib only.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execFileSync } = require('child_process');
12
+ const { isDeepStrictEqual } = require('util');
13
+
14
+ const { RECEIPT, MCP_PATH } = require('./install.cjs');
15
+ const { loadManifest, inProfile } = require('./manifest.cjs');
16
+
17
+ function result(name, failures, detail) {
18
+ return { name, ok: failures.length === 0, failures, detail };
19
+ }
20
+
21
+ function readReceipt(root) {
22
+ return JSON.parse(fs.readFileSync(path.join(root, RECEIPT), 'utf8'));
23
+ }
24
+
25
+ // ── managed-integrity ────────────────────────────────────────────────────────
26
+ //
27
+ // The server-side replacement for the demoted local managed-guard hook: every kernel-managed file must
28
+ // byte-match its canonical source in the kernel package payload — i.e. it has not drifted from
29
+ // kernel-owned content. The managed SET is anchored to the kernel `manifest.json` (the source of
30
+ // truth), NOT to the install's `wrxn.install.json` receipt (CF-2): the receipt is itself unprotected
31
+ // and never integrity-checked, so deriving the set from it let an attacker edit a managed file and
32
+ // drop/reclassify its receipt entry to pass the check (security MED-1; HIGH if an install ever takes
33
+ // untrusted fork PRs). The receipt is trusted only for the install PROFILE (project|workspace), which
34
+ // selects which managed files were laid. `.mcp.json` is the one file that is byte-DIVERGENT yet still
35
+ // checked: it is managed but operator-MERGED (init/update fold the recon-wrxn server into the
36
+ // operator's other servers rather than overwrite), so a blanket byte-equality is wrong AND a blanket
37
+ // skip is a hole — an injected/swapped MCP `command` auto-executes on session open (security MED-2 /
38
+ // SEC-MED-1, issue 10). Instead it is validated merge-aware (`mcpServerFailures`): every KERNEL-MANAGED
39
+ // server entry (the keys in payload/.mcp.json, e.g. recon-wrxn) must deep-equal the payload — the exact
40
+ // post-condition of install.cjs `mergeMcpServer` — while operator-ADDED servers (keys not in the
41
+ // payload) are the operator's own repo content and pass. A repo with no receipt is not a wrxn install
42
+ // (nothing managed to verify) → passes; an UNREADABLE receipt is a real defect → fails.
43
+ function managedIntegrity(root, opts) {
44
+ const pkgRoot = (opts && opts.pkgRoot) || path.join(__dirname, '..');
45
+ const receiptPath = path.join(root, RECEIPT);
46
+ if (!fs.existsSync(receiptPath)) {
47
+ return result('managed-integrity', [], 'no wrxn.install.json — not a wrxn install, nothing to verify');
48
+ }
49
+ let receipt;
50
+ try {
51
+ receipt = readReceipt(root);
52
+ } catch (err) {
53
+ return result('managed-integrity', [`${RECEIPT} is unreadable: ${err.message}`], 'receipt corrupt');
54
+ }
55
+ let manifest;
56
+ try {
57
+ manifest = loadManifest(path.join(pkgRoot, 'manifest.json'));
58
+ } catch (err) {
59
+ return result('managed-integrity', [`kernel manifest is unreadable: ${err.message}`], 'manifest missing');
60
+ }
61
+
62
+ const profile = receipt.profile || 'project';
63
+ const managed = manifest.files.filter((f) => f.class === 'managed');
64
+ const failures = [];
65
+ let checked = 0;
66
+ for (const f of managed) {
67
+ const installed = path.join(root, f.path);
68
+ const canonical = path.join(pkgRoot, 'payload', f.path);
69
+ if (!fs.existsSync(canonical)) continue; // no kernel source to compare against → cannot be drift
70
+ if (!fs.existsSync(installed)) {
71
+ // An in-profile managed file MUST be present; an out-of-profile one is legitimately absent (a
72
+ // project install does not lay workspace files). The profile comes from the receipt, but it can
73
+ // only EXCLUDE a missing-file check — it can never suppress a present, drifted file (below).
74
+ if (inProfile(f.profile, profile)) {
75
+ failures.push(`${f.path} — managed file is missing from the install`);
76
+ }
77
+ continue;
78
+ }
79
+ // Present on disk → it must match the kernel source REGARDLESS of the claimed profile, so a flipped
80
+ // receipt profile cannot hide a tampered workspace-managed file (CF-2 defense-in-depth). `.mcp.json`
81
+ // is operator-MERGED so byte-equality is wrong → validate its kernel-managed server entries instead.
82
+ checked++;
83
+ if (f.path === MCP_PATH) {
84
+ failures.push(...mcpServerFailures(installed, canonical));
85
+ } else if (!fs.readFileSync(installed).equals(fs.readFileSync(canonical))) {
86
+ failures.push(`${f.path} — drifted from the kernel-owned source`);
87
+ }
88
+ }
89
+ return result('managed-integrity', failures, `${checked} managed file(s) checked`);
90
+ }
91
+
92
+ // Merge-aware integrity for the operator-MERGED `.mcp.json`: assert each KERNEL-MANAGED server entry
93
+ // (a key present in the payload `.mcp.json`, e.g. recon-wrxn) is present in the install and deep-equals
94
+ // the payload — the exact post-condition install.cjs `mergeMcpServer` writes — so a swapped/injected
95
+ // launch `command` on a kernel server is caught. Operator-ADDED server keys (absent from the payload)
96
+ // are the operator's own repo content and are intentionally NOT judged (no false positive). Corrupt
97
+ // install JSON fails closed here too (jsonValidity also catches it). The payload is kernel-trusted; a
98
+ // thrown payload parse propagates to the entrypoint (exit non-zero) — fail-closed by design.
99
+ function mcpServerFailures(installed, canonical) {
100
+ let install;
101
+ try {
102
+ install = JSON.parse(fs.readFileSync(installed, 'utf8'));
103
+ } catch (err) {
104
+ return [`${MCP_PATH} — invalid JSON: ${err.message}`];
105
+ }
106
+ const payloadServers = (JSON.parse(fs.readFileSync(canonical, 'utf8')) || {}).mcpServers || {};
107
+ const installServers = (install && install.mcpServers) || {};
108
+ const failures = [];
109
+ for (const key of Object.keys(payloadServers)) {
110
+ if (!(key in installServers)) {
111
+ failures.push(`${MCP_PATH} — kernel-managed MCP server "${key}" is missing`);
112
+ } else if (!isDeepStrictEqual(installServers[key], payloadServers[key])) {
113
+ failures.push(`${MCP_PATH} — kernel-managed MCP server "${key}" drifted from the kernel-owned source`);
114
+ }
115
+ }
116
+ return failures;
117
+ }
118
+
119
+ // ── wiki-lint ─────────────────────────────────────────────────────────────────
120
+ //
121
+ // Every human-prose wiki page must carry a well-formed frontmatter block with the required keys
122
+ // (name / description / tier) — the same contract the session-close wiki-lint hook flags, enforced
123
+ // here server-side. The machine-written `_rules`/`_slots` tiers are deliberately out of scope (they
124
+ // are not human-prose frontmatter). An empty/absent wiki tree passes. Re-implemented inline rather
125
+ // than importing the payload hook so lib stays free of payload dependencies.
126
+ const WIKI_TIERS = ['concepts', 'decisions', 'gotchas', 'sessions'];
127
+ const WIKI_REQUIRED_KEYS = ['name', 'description', 'tier'];
128
+
129
+ // Return the reason a page is malformed, or null when it is well-formed.
130
+ function lintWikiPage(text) {
131
+ const src = String(text || '');
132
+ if (!src.startsWith('---')) return 'no frontmatter';
133
+ const end = src.indexOf('\n---', 3);
134
+ if (end < 0) return 'unterminated frontmatter';
135
+ const fm = src.slice(3, end);
136
+ const missing = WIKI_REQUIRED_KEYS.filter((k) => !new RegExp(`^${k}\\s*:`, 'm').test(fm));
137
+ if (missing.length) return `missing ${missing.join('/')}`;
138
+ return null;
139
+ }
140
+
141
+ function wikiLint(root) {
142
+ const failures = [];
143
+ for (const tier of WIKI_TIERS) {
144
+ const dir = path.join(root, '.wrxn', 'wiki', tier);
145
+ let names;
146
+ try {
147
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.md'));
148
+ } catch {
149
+ continue; // missing tier → nothing to lint
150
+ }
151
+ for (const name of names) {
152
+ let text;
153
+ try {
154
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
155
+ } catch {
156
+ continue;
157
+ }
158
+ const reason = lintWikiPage(text);
159
+ if (reason) failures.push(`${tier}/${name} — ${reason}`);
160
+ }
161
+ }
162
+ return result('wiki-lint', failures, failures.length ? `${failures.length} malformed page(s)` : 'wiki frontmatter clean');
163
+ }
164
+
165
+ // ── synapse-manifest lint ─────────────────────────────────────────────────────
166
+ //
167
+ // The SYNAPSE engine reads `.synapse/manifest` (flat KEY=VALUE) on every prompt and, for each active
168
+ // domain, loads its rules from the sibling file `.synapse/<domain-lowercased>`. A manifest that marks
169
+ // a domain active but ships no domain file is a silent divergence — the engine reads nothing. This
170
+ // lint enforces the contract: the manifest exists and parses, and every active domain (except the
171
+ // special CONSTITUTION, whose body is sourced from .claude/constitution.md) has its domain file.
172
+ function synapseManifestLint(root) {
173
+ const manifestPath = path.join(root, '.synapse', 'manifest');
174
+ let text;
175
+ try {
176
+ text = fs.readFileSync(manifestPath, 'utf8');
177
+ } catch {
178
+ return result('synapse-manifest', ['.synapse/manifest is absent or unreadable'], 'no manifest');
179
+ }
180
+
181
+ // Collect the active domains: a line `<NAME>_STATE=active` marks NAME active.
182
+ const active = new Set();
183
+ for (const line of text.split('\n')) {
184
+ const trimmed = line.trim();
185
+ if (!trimmed || trimmed.startsWith('#')) continue;
186
+ const m = trimmed.match(/^(.+)_STATE=(.*)$/);
187
+ if (m && m[2].trim() === 'active') active.add(m[1]);
188
+ }
189
+
190
+ if (active.size === 0) {
191
+ return result('synapse-manifest', ['.synapse/manifest declares no active domain'], 'empty manifest');
192
+ }
193
+
194
+ const failures = [];
195
+ for (const name of active) {
196
+ if (name === 'CONSTITUTION') continue; // sourced from .claude/constitution.md, not a domain file
197
+ const domainFile = path.join(root, '.synapse', name.toLowerCase());
198
+ if (!fs.existsSync(domainFile)) {
199
+ failures.push(`${name} — active in the manifest but .synapse/${name.toLowerCase()} is missing`);
200
+ }
201
+ }
202
+ return result('synapse-manifest', failures, `${active.size} active domain(s) checked`);
203
+ }
204
+
205
+ // ── JSON validity ─────────────────────────────────────────────────────────────
206
+ //
207
+ // Every wrxn-owned JSON file in the install must parse. Scope is bounded to the install receipt plus
208
+ // the `.json` paths in the manifest (e.g. .mcp.json, .recon-wrxn.json) — never the whole tree, so it
209
+ // can't false-positive on node_modules or an operator's own fixtures. A corrupt receipt or a
210
+ // hand-broken config is a real defect CI should catch.
211
+ function jsonPaths(pkgRoot) {
212
+ const paths = [RECEIPT];
213
+ try {
214
+ const manifest = loadManifest(path.join(pkgRoot, 'manifest.json'));
215
+ for (const entry of manifest.files) {
216
+ if (entry.path.endsWith('.json')) paths.push(entry.path);
217
+ }
218
+ } catch {
219
+ /* no manifest reachable → still validate the receipt */
220
+ }
221
+ return paths;
222
+ }
223
+
224
+ function jsonValidity(root, opts) {
225
+ const pkgRoot = (opts && opts.pkgRoot) || path.join(__dirname, '..');
226
+ const failures = [];
227
+ for (const rel of jsonPaths(pkgRoot)) {
228
+ const p = path.join(root, rel);
229
+ if (!fs.existsSync(p)) continue;
230
+ try {
231
+ JSON.parse(fs.readFileSync(p, 'utf8'));
232
+ } catch (err) {
233
+ failures.push(`${rel} — invalid JSON: ${err.message}`);
234
+ }
235
+ }
236
+ return result('json-validity', failures, `${jsonPaths(pkgRoot).length} json path(s) checked`);
237
+ }
238
+
239
+ // ── node --check syntax ───────────────────────────────────────────────────────
240
+ //
241
+ // Every wrxn-shipped `.cjs` (the hooks + the .wrxn adapters) must parse — a broken hook fails open
242
+ // silently in production, so a syntax error must never reach an install un-caught. Scope is the
243
+ // manifest's `.cjs` entries present in the install; each is verified with a real `node --check` (parse
244
+ // only, never executed). Returns ok when all parse, else names each offender.
245
+ function checkSyntax(file) {
246
+ try {
247
+ execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
248
+ return null;
249
+ } catch (err) {
250
+ const detail = String((err && err.stderr) || (err && err.message) || 'parse error').split('\n').find(Boolean) || 'parse error';
251
+ return detail.trim();
252
+ }
253
+ }
254
+
255
+ function cjsPaths(pkgRoot) {
256
+ try {
257
+ const manifest = loadManifest(path.join(pkgRoot, 'manifest.json'));
258
+ return manifest.files.filter((e) => e.path.endsWith('.cjs')).map((e) => e.path);
259
+ } catch {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ function nodeCheck(root, opts) {
265
+ const pkgRoot = (opts && opts.pkgRoot) || path.join(__dirname, '..');
266
+ const failures = [];
267
+ let checked = 0;
268
+ for (const rel of cjsPaths(pkgRoot)) {
269
+ const p = path.join(root, rel);
270
+ if (!fs.existsSync(p)) continue;
271
+ checked++;
272
+ const err = checkSyntax(p);
273
+ if (err) failures.push(`${rel} — ${err}`);
274
+ }
275
+ return result('node-check', failures, `${checked} .cjs file(s) parsed`);
276
+ }
277
+
278
+ // ── the universal gate ────────────────────────────────────────────────────────
279
+ //
280
+ // Run every universal check over an install root. This is the gate the `wrxn ci` CLI (and thus the
281
+ // wrxn-ci workflow) invokes — independent of any project suite, so even a no-suite repo gets a real,
282
+ // fail-able check. ok is the AND of all checks. The project `WRXN_TEST_CMD` runs as its own workflow
283
+ // step (skipped when `true`/empty), NOT here — these are the kernel-universal checks only.
284
+ function runChecks(root, opts) {
285
+ const o = opts || {};
286
+ const results = [
287
+ managedIntegrity(root, o),
288
+ wikiLint(root),
289
+ synapseManifestLint(root),
290
+ jsonValidity(root, o),
291
+ nodeCheck(root, o),
292
+ ];
293
+ return { ok: results.every((r) => r.ok), results };
294
+ }
295
+
296
+ module.exports = { managedIntegrity, wikiLint, synapseManifestLint, jsonValidity, nodeCheck, runChecks };
package/lib/executor.cjs CHANGED
@@ -76,11 +76,14 @@ const EXECUTORS = {
76
76
  required: GENERIC_REQUIRED,
77
77
  },
78
78
  devops: {
79
- skill: null, // the integration / push executor — instructions; the ONLY type that may push
79
+ skill: null, // the integration / promote executor — instructions; the ONLY type that may push
80
80
  instructions: [
81
- 'You are the devops integration executor — the ONLY executor authorized to push. Integrate the',
82
- 'reviewed + security-passed + qa-walked track to the trunk: verify the review marker (review-<id>.md)',
83
- '+ a green suite exist, then authorize the push by setting WRXN_ACTIVE_AGENT to devops under the `env` key of .claude/settings.local.json (an inline command-scoped assignment never reaches the gate hook), push, then REMOVE WRXN_ACTIVE_AGENT from .claude/settings.local.json — a persistent flag defeats the anti-accidental-push gate. This is the single path through the push gate.',
81
+ 'You are the devops integration executor — the ONLY executor authorized to promote a track to the',
82
+ 'trunk. Verify the track is reviewed + security-passed + qa-walked and you are on the reviewed branch,',
83
+ 'then promote with ONE command: `wrxn ship --title "<conventional PR title>"` it pushes the branch,',
84
+ 'opens a PR, and arms auto-merge (`gh pr merge --auto --squash`). No env flag, no settings file, no',
85
+ 'GitHub clicks. Confirm auto-merge is armed; the server-enforced CI ruleset then merges to the trunk',
86
+ 'the instant CI is green. This is the single promote path through the push gate.',
84
87
  ],
85
88
  artifact: 'authorized-push',
86
89
  canPush: true,
package/lib/install.cjs CHANGED
@@ -86,6 +86,9 @@ function init(opts) {
86
86
  ensureGitignoreLine(target, '.recon-wrxn/');
87
87
  // the recall-surface hook writes per-install access-recency state here (harvest-08) — runtime, not committed.
88
88
  ensureGitignoreLine(target, '.wrxn/reinforce.json');
89
+ // the memory-synth gemini fallback reads GEMINI_API_KEY from `.env` — ignore it so the secret is
90
+ // never committed (the synth's doc-comment calls it the install's gitignored `.env`).
91
+ ensureGitignoreLine(target, '.env');
89
92
 
90
93
  writeReceipt(target, { version, profile, laid, skipped, merged, brownfield });
91
94