@gcunharodrigues/wrxn 0.9.0 → 0.11.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 (38) hide show
  1. package/bin/wrxn.cjs +300 -0
  2. package/lib/agent-conformance.cjs +92 -0
  3. package/lib/ci-checks.cjs +296 -0
  4. package/lib/compass-coverage.cjs +53 -0
  5. package/lib/executor.cjs +7 -4
  6. package/lib/flow-status.cjs +87 -0
  7. package/lib/protect.cjs +195 -0
  8. package/lib/release.cjs +51 -0
  9. package/lib/ship.cjs +76 -0
  10. package/lib/update.cjs +15 -1
  11. package/manifest.json +40 -10
  12. package/migrations/005-protect-main-gate.cjs +32 -0
  13. package/migrations/006-refresh-routing-rule.cjs +56 -0
  14. package/package.json +1 -1
  15. package/payload/.claude/agents/builder.md +59 -0
  16. package/payload/.claude/agents/devops.md +65 -0
  17. package/payload/.claude/agents/qa-walker.md +58 -0
  18. package/payload/.claude/agents/researcher.md +56 -0
  19. package/payload/.claude/agents/reviewer.md +60 -0
  20. package/payload/.claude/agents/security.md +59 -0
  21. package/payload/.claude/constitution.md +10 -6
  22. package/payload/.claude/hooks/enforce-managed-guard.cjs +14 -11
  23. package/payload/.claude/hooks/enforce-managed-precommit.cjs +11 -8
  24. package/payload/.claude/hooks/enforce-pipeline-adherence.cjs +116 -0
  25. package/payload/.claude/settings.json +7 -4
  26. package/payload/.claude/skills/compass/SKILL.md +101 -0
  27. package/payload/.claude/skills/qa-walk/SKILL.md +33 -0
  28. package/payload/.claude/skills/synapse/SKILL.md +4 -4
  29. package/payload/.claude/skills/synapse/references/domains.md +2 -2
  30. package/payload/.claude/skills/synapse/references/layers.md +3 -3
  31. package/payload/.github/workflows/wrxn-ci.yml +54 -0
  32. package/payload/.synapse/global +2 -2
  33. package/payload/.synapse/manifest +4 -1
  34. package/payload/.synapse/pipeline +7 -6
  35. package/payload/.synapse/routing +1 -1
  36. package/payload/.claude/hooks/enforce-push-authority.cjs +0 -52
  37. package/payload/.claude/hooks/enforce-review-marker.cjs +0 -62
  38. package/payload/.claude/hooks/enforce-tests-on-push.cjs +0 -40
@@ -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 };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // compass coverage guard (wrxn-kernel flow-04).
4
+ // compass/SKILL.md carries a static ```buckets``` block routing every installed skill to a flow bucket
5
+ // (dev-pipeline / knowledge / setup-health / meta / cross-session). The runtime live-read in the skill
6
+ // body is the resilience layer; THIS is the drift-guard on the static map — an installed skill missing
7
+ // from every bucket is an orphan, i.e. the map fell behind a newly-added skill.
8
+ //
9
+ // Pure data transforms (no I/O), mirroring lib/executor.cjs: parseBuckets is the tolerant parser, the
10
+ // test reads the real SKILL.md + skills dir around them.
11
+
12
+ /**
13
+ * Parse the fenced ```buckets``` block out of compass/SKILL.md into { bucket: [skill, …] }.
14
+ * Each line is `bucket: a, b, c`. Tolerant: a missing block, blank lines, comment (#) lines, or
15
+ * lines without a colon are skipped rather than thrown on.
16
+ */
17
+ function parseBuckets(skillMd) {
18
+ const text = String(skillMd || '');
19
+ const m = text.match(/```buckets\s*\n([\s\S]*?)```/);
20
+ if (!m) return {};
21
+
22
+ const buckets = {};
23
+ for (const line of m[1].split('\n')) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith('#')) continue;
26
+ const idx = trimmed.indexOf(':');
27
+ if (idx === -1) continue;
28
+ const bucket = trimmed.slice(0, idx).trim();
29
+ const skills = trimmed
30
+ .slice(idx + 1)
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .filter(Boolean);
34
+ if (bucket) buckets[bucket] = skills;
35
+ }
36
+ return buckets;
37
+ }
38
+
39
+ /**
40
+ * The coverage check: every installed skill must appear in some bucket. Returns { ok, orphans },
41
+ * where orphans is the list of installed skills absent from every bucket (the static map drifted
42
+ * behind a newly-added skill). Pure: the caller supplies the installed skill list and parsed buckets.
43
+ */
44
+ function compassCoverage(installedSkills, buckets) {
45
+ const routed = new Set();
46
+ for (const skills of Object.values(buckets || {})) {
47
+ for (const s of skills || []) routed.add(s);
48
+ }
49
+ const orphans = (installedSkills || []).filter((s) => !routed.has(s));
50
+ return { ok: orphans.length === 0, orphans };
51
+ }
52
+
53
+ module.exports = { parseBuckets, compassCoverage };
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,
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // Pure flow-status aggregator (wrxn-kernel flow-05).
4
+ // flowStatus(issues, artifacts) reconstructs each slice's gate progress from durable artifacts —
5
+ // no separate mutable state, no I/O, no time-of-day logic. The CLI (bin/wrxn.cjs flow status)
6
+ // wraps this with the actual file reads and git log detection.
7
+
8
+ // The four pipeline gates in order: build (green commit) → review (marker) → security → qa (walk).
9
+ const GATES = ['build', 'review', 'security', 'qa'];
10
+
11
+ /**
12
+ * Map one issue's artifact entry to gate booleans.
13
+ * A gate is done only when its artifact field is a non-empty truthy string — never a false pass.
14
+ */
15
+ function gatesFor(artifact) {
16
+ const a = artifact || {};
17
+ return {
18
+ build: !!(a.greenCommit && typeof a.greenCommit === 'string' && a.greenCommit.trim()),
19
+ review: !!(a.reviewMarker && typeof a.reviewMarker === 'string' && a.reviewMarker.trim()),
20
+ security: !!(a.securityReport && typeof a.securityReport === 'string' && a.securityReport.trim()),
21
+ qa: !!(a.walkFindings && typeof a.walkFindings === 'string' && a.walkFindings.trim()),
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Derive one slice's overall state from its gate booleans and whether it still has an
27
+ * UNRESOLVED blocker (a listed dependency whose own slice is not yet fully done).
28
+ *
29
+ * done — all four gates done (wins over any listed blocker: completion is never masked)
30
+ * queued — an unresolved blocker, or no gates done at all
31
+ * stalled — build done but review not done (stuck at the first critical handoff, never reviewed)
32
+ * in-progress — any other partial completion (review done, or build+review but security/qa pending)
33
+ */
34
+ function sliceState(gates, hasUnresolvedBlocker) {
35
+ const { build, review, security, qa } = gates;
36
+ if (build && review && security && qa) return 'done';
37
+ if (hasUnresolvedBlocker) return 'queued';
38
+ if (!build && !review && !security && !qa) return 'queued';
39
+ if (build && !review) return 'stalled';
40
+ return 'in-progress';
41
+ }
42
+
43
+ /**
44
+ * flowStatus(issues, artifacts) → per-issue board array.
45
+ *
46
+ * issues — Array<{ id: string, title?: string, blockedBy?: string[] }>
47
+ * artifacts — { [id: string]: { greenCommit?, reviewMarker?, securityReport?, walkFindings? } }
48
+ *
49
+ * Returns Array<{ id, title, gates: { build, review, security, qa }, state }> where each gate
50
+ * value is 'done'|'pending' and state is 'done'|'in-progress'|'queued'|'stalled'.
51
+ */
52
+ function flowStatus(issues, artifacts) {
53
+ const arts = artifacts || {};
54
+ const list = issues || [];
55
+
56
+ // First pass: each slice's gate booleans + the set of fully-done slice ids (all four gates).
57
+ const gatesById = new Map();
58
+ const doneIds = new Set();
59
+ for (const issue of list) {
60
+ const g = gatesFor(arts[issue.id]);
61
+ gatesById.set(issue.id, g);
62
+ if (g.build && g.review && g.security && g.qa) doneIds.add(issue.id);
63
+ }
64
+
65
+ // Second pass: resolve each slice's state. A blocker is RESOLVED once its own slice is fully done;
66
+ // a slice is queued only when it still lists an UNRESOLVED blocker (or has no gate progress).
67
+ return list.map((issue) => {
68
+ const id = issue.id;
69
+ const g = gatesById.get(id);
70
+ const blockedBy = Array.isArray(issue.blockedBy) ? issue.blockedBy : [];
71
+ const hasUnresolvedBlocker = blockedBy.some((b) => !doneIds.has(b));
72
+ const state = sliceState(g, hasUnresolvedBlocker);
73
+ return {
74
+ id,
75
+ title: issue.title || '',
76
+ gates: {
77
+ build: g.build ? 'done' : 'pending',
78
+ review: g.review ? 'done' : 'pending',
79
+ security: g.security ? 'done' : 'pending',
80
+ qa: g.qa ? 'done' : 'pending',
81
+ },
82
+ state,
83
+ };
84
+ });
85
+ }
86
+
87
+ module.exports = { flowStatus, GATES };
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ // WRXN protect — the server-side hard gate (gate-redesign gate-02).
4
+ //
5
+ // A client-side hook can never be hard enforcement (it gates one tool surface, not the repository).
6
+ // The only control that survives every bypass — human terminal, IDE, MCP, API, `--no-verify`, the
7
+ // 2026-06-19 settings.local.json disarm bug — is a SERVER-SIDE GitHub branch ruleset. This module
8
+ // builds and applies the `wrxn-main-gate` ruleset on a repo's `origin`: block direct push to the
9
+ // default branch, require a PR (0 approvals — a solo account auto-merges its own PR), require the
10
+ // `wrxn-ci` status check, require the branch up to date (race-safety), and NO bypass actor.
11
+ //
12
+ // Application is idempotent (create-or-update BY NAME — re-run = no-op) and fail-soft (no `gh`, not a
13
+ // repo admin, no remote, or any non-zero exit → a clear message and exit 0, never a throw, so it can
14
+ // never break `wrxn update` on a remote-less install). REUSE of lib/connect.cjs's injectable-invoker
15
+ // shape: an injected `invoker` makes unit tests deterministic; the real `spawnSync gh`/`git` runs only
16
+ // when no invoker is injected (the CLI layer) — that is what makes the application "validated by
17
+ // invocation". The ruleset is repo-agnostic via `~DEFAULT_BRANCH`, so the SAME spec protects any
18
+ // default-main repo (the kernel, every install, and the recon-wrxn sibling — gate-06).
19
+ //
20
+ // lib/protect.cjs is package code (invoked via bin/wrxn.cjs), NOT payload — no manifest entry,
21
+ // consistent with lib/connect.cjs / lib/ship.cjs / lib/executor.cjs / lib/onboard.cjs.
22
+
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const RULESET_NAME = 'wrxn-main-gate';
26
+ const DEFAULT_CHECK = 'wrxn-ci';
27
+
28
+ /**
29
+ * Build the `wrxn-main-gate` GitHub repository-ruleset payload. PURE — a fresh, independent object
30
+ * each call, no side effects. The authoritative payload (gate-02 API contract): block direct push to
31
+ * the default branch, require a PR with 0 approvals, require `requiredCheck` strict (up-to-date), no
32
+ * bypass actor. `~DEFAULT_BRANCH` keeps it repo-agnostic (no hard-coded `main`).
33
+ * @param {{ requiredCheck?: string }} [opts]
34
+ * @returns {object} the ruleset payload for POST/PUT `/repos/{slug}/rulesets`
35
+ */
36
+ function buildRulesetSpec({ requiredCheck = DEFAULT_CHECK } = {}) {
37
+ return {
38
+ name: RULESET_NAME,
39
+ target: 'branch',
40
+ enforcement: 'active',
41
+ bypass_actors: [],
42
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
43
+ rules: [
44
+ { type: 'deletion' },
45
+ { type: 'non_fast_forward' },
46
+ {
47
+ type: 'pull_request',
48
+ parameters: {
49
+ required_approving_review_count: 0,
50
+ dismiss_stale_reviews_on_push: false,
51
+ require_code_owner_review: false,
52
+ require_last_push_approval: false,
53
+ required_review_thread_resolution: false,
54
+ },
55
+ },
56
+ {
57
+ type: 'required_status_checks',
58
+ parameters: {
59
+ strict_required_status_checks_policy: true,
60
+ required_status_checks: [{ context: requiredCheck }],
61
+ },
62
+ },
63
+ ],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The real command invoker — a single spawnSync (mirrors lib/ship.cjs / lib/connect.cjs defaultInvoke).
69
+ * Captures stdout (needed to parse the ruleset list) and passes a body on stdin (the create/update).
70
+ * The CLI layer wires this implicitly (no injected invoker) — that is what makes the apply real.
71
+ * @returns {{ ok:boolean, status:number|null, stdout:string, stderr:string }}
72
+ */
73
+ function defaultInvoke({ cmd, args, input }) {
74
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input: input || undefined });
75
+ // Judge a child by its EXIT STATUS, not by whether we finished writing its stdin. The real `gh api
76
+ // ... --input -` reads the body from stdin; if it (or a stub) exits before draining it, spawnSync's
77
+ // stdin write races the child's exit and surfaces an EPIPE in `r.error` — yet it still captures the
78
+ // real `status`/`stdout`. Only a spawn that NEVER ran (e.g. ENOENT — `status` stays null) is a true
79
+ // failure here; an EPIPE on a child that exited 0 is a success, not a "command not found" skip.
80
+ if (r.error && r.status == null) {
81
+ return { ok: false, status: null, stdout: '', stderr: r.error.code || r.error.message };
82
+ }
83
+ return { ok: r.status === 0, status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
84
+ }
85
+
86
+ /** A fail-soft skip result — a logged-elsewhere reason, never a throw, so it never breaks `wrxn update`. */
87
+ function softSkip(reason) {
88
+ return { ok: false, action: 'skipped', reason };
89
+ }
90
+
91
+ /** One-line failure detail from an invoker result (status + first stderr line). */
92
+ function detailOf(r) {
93
+ const status = r.status == null ? 'no exit (command not found?)' : `exit ${r.status}`;
94
+ const err = String(r.stderr || '').trim().split('\n')[0];
95
+ return err ? `${status}: ${err}` : status;
96
+ }
97
+
98
+ /**
99
+ * Apply the `wrxn-main-gate` ruleset to `slug`'s repo via `gh api`. Idempotent (list → create-or-update
100
+ * BY NAME, so a re-run converges to a no-op) and fail-soft (no gh / not admin / no remote / any
101
+ * non-zero exit → a skipped result carrying a clear reason; NEVER throws). The invoker is injectable so
102
+ * unit tests are deterministic; the CLI layer uses the real `gh` spawn.
103
+ * @param {{ invoker?:Function, slug?:string, requiredCheck?:string }} [opts]
104
+ * @returns {{ ok:boolean, action:'created'|'updated'|'skipped', slug?:string, name?:string, detail?:string, reason?:string }}
105
+ */
106
+ function applyProtection({ invoker, slug, requiredCheck = DEFAULT_CHECK } = {}) {
107
+ const run = invoker || defaultInvoke;
108
+
109
+ if (!slug || typeof slug !== 'string' || slug.trim() === '') {
110
+ return softSkip('no origin remote — the wrxn-main-gate ruleset is not applied (a remote-less install is unprotected here; protection lands when it gains a GitHub origin)');
111
+ }
112
+
113
+ // 1. list existing rulesets — the idempotency lookup (find one named wrxn-main-gate).
114
+ const list = run({ cmd: 'gh', args: ['api', `/repos/${slug}/rulesets`] });
115
+ if (!list.ok) {
116
+ return softSkip(`could not list rulesets on ${slug} (${detailOf(list)}) — is gh installed, authenticated, and admin on the repo? skipping (exit 0)`);
117
+ }
118
+ let rulesets;
119
+ try {
120
+ rulesets = JSON.parse(list.stdout || '[]');
121
+ } catch {
122
+ return softSkip(`unexpected gh output listing rulesets on ${slug} — skipping`);
123
+ }
124
+ const existing = Array.isArray(rulesets) ? rulesets.find((r) => r && r.name === RULESET_NAME) : null;
125
+ const body = JSON.stringify(buildRulesetSpec({ requiredCheck }));
126
+
127
+ // 2. update in place (PUT to the existing id) if present, else create (POST) — re-run = no-op.
128
+ if (existing) {
129
+ const put = run({ cmd: 'gh', args: ['api', '--method', 'PUT', `/repos/${slug}/rulesets/${existing.id}`, '--input', '-'], input: body });
130
+ if (!put.ok) {
131
+ return softSkip(`could not update the ${RULESET_NAME} ruleset on ${slug} (${detailOf(put)}) — skipping`);
132
+ }
133
+ return { ok: true, action: 'updated', slug, name: RULESET_NAME, detail: `${RULESET_NAME} updated on ${slug}` };
134
+ }
135
+ const post = run({ cmd: 'gh', args: ['api', '--method', 'POST', `/repos/${slug}/rulesets`, '--input', '-'], input: body });
136
+ if (!post.ok) {
137
+ return softSkip(`could not create the ${RULESET_NAME} ruleset on ${slug} (${detailOf(post)}) — is the token a repo admin? skipping`);
138
+ }
139
+ return { ok: true, action: 'created', slug, name: RULESET_NAME, detail: `${RULESET_NAME} created on ${slug}` };
140
+ }
141
+
142
+ /** A well-formed GitHub `owner/repo` slug: owner starts alphanumeric, repo is alnum/`.`/`_`/`-`. */
143
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
144
+
145
+ /**
146
+ * Parse an `owner/repo` slug from a git remote URL (ssh `git@host:owner/repo.git`, https
147
+ * `https://host/owner/repo.git`, with or without the `.git` suffix / a trailing slash) or a bare
148
+ * `owner/repo`. The captured slug is then VALIDATED against the GitHub owner/repo grammar (gate-02
149
+ * LOW-1, defense-in-depth): `..` traversal, spaces, `;`, `$()`, backticks, and `--flag`-looking text
150
+ * are rejected → null, so a malformed remote fail-soft-skips instead of reaching `gh` as data. Returns
151
+ * null when no well-formed slug can be read — protection then fail-soft-skips.
152
+ */
153
+ function parseSlug(url) {
154
+ const s = String(url || '').trim();
155
+ if (!s) return null;
156
+ const m = s.match(/(?:^|[:/])([^/:]+\/[^/:]+?)(?:\.git)?\/?$/);
157
+ const slug = m ? m[1] : null;
158
+ return slug && SLUG_RE.test(slug) ? slug : null;
159
+ }
160
+
161
+ /**
162
+ * Derive the `owner/repo` slug of a repo's `origin` remote. Injectable git invoker for tests; the
163
+ * real `git` spawn runs at the CLI/update/migration layer. Returns null on no origin / parse failure
164
+ * (→ applyProtection soft-skips) — never throws.
165
+ */
166
+ function originSlug(root, { invoker } = {}) {
167
+ const run = invoker || defaultInvoke;
168
+ const r = run({ cmd: 'git', args: ['-C', root, 'remote', 'get-url', 'origin'] });
169
+ if (!r.ok) return null;
170
+ return parseSlug(r.stdout);
171
+ }
172
+
173
+ /**
174
+ * Derive the origin slug of the install at `root` and apply the ruleset to it. The single entry point
175
+ * the CLI (`wrxn protect`), `wrxn update`, and migration 005 share. Repo-agnostic (the slug is read
176
+ * from origin, so it works for the kernel, any install, and recon-wrxn) and fail-soft end-to-end (a
177
+ * remote-less root → applyProtection's no-remote skip; never throws). Both invokers are injectable.
178
+ * @param {string} root install/repo root
179
+ * @param {{ gitInvoker?:Function, ghInvoker?:Function, requiredCheck?:string }} [opts]
180
+ */
181
+ function protectOrigin(root, { gitInvoker, ghInvoker, requiredCheck } = {}) {
182
+ const slug = originSlug(root, { invoker: gitInvoker });
183
+ return applyProtection({ slug, invoker: ghInvoker, requiredCheck });
184
+ }
185
+
186
+ module.exports = {
187
+ RULESET_NAME,
188
+ DEFAULT_CHECK,
189
+ buildRulesetSpec,
190
+ defaultInvoke,
191
+ applyProtection,
192
+ parseSlug,
193
+ originSlug,
194
+ protectOrigin,
195
+ };