@gcunharodrigues/wrxn 0.10.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.
- package/bin/wrxn.cjs +155 -0
- package/lib/ci-checks.cjs +296 -0
- package/lib/executor.cjs +7 -4
- package/lib/protect.cjs +195 -0
- package/lib/release.cjs +51 -0
- package/lib/ship.cjs +76 -0
- package/lib/update.cjs +15 -1
- package/manifest.json +6 -11
- package/migrations/005-protect-main-gate.cjs +32 -0
- package/migrations/006-refresh-routing-rule.cjs +56 -0
- package/package.json +1 -1
- package/payload/.claude/agents/builder.md +2 -1
- package/payload/.claude/agents/devops.md +34 -29
- package/payload/.claude/agents/qa-walker.md +2 -1
- package/payload/.claude/agents/researcher.md +2 -1
- package/payload/.claude/agents/reviewer.md +2 -1
- package/payload/.claude/agents/security.md +2 -1
- package/payload/.claude/constitution.md +10 -6
- package/payload/.claude/hooks/enforce-managed-guard.cjs +14 -11
- package/payload/.claude/hooks/enforce-managed-precommit.cjs +11 -8
- package/payload/.claude/hooks/enforce-pipeline-adherence.cjs +116 -0
- package/payload/.claude/settings.json +7 -4
- package/payload/.claude/skills/compass/SKILL.md +6 -1
- package/payload/.claude/skills/synapse/SKILL.md +4 -4
- package/payload/.claude/skills/synapse/references/domains.md +2 -2
- package/payload/.claude/skills/synapse/references/layers.md +3 -3
- package/payload/.github/workflows/wrxn-ci.yml +54 -0
- package/payload/.synapse/global +2 -2
- package/payload/.synapse/manifest +4 -3
- package/payload/.synapse/pipeline +1 -0
- package/payload/.synapse/routing +1 -1
- package/payload/.claude/hooks/enforce-push-authority.cjs +0 -52
- package/payload/.claude/hooks/enforce-review-marker.cjs +0 -62
- package/payload/.claude/hooks/enforce-tests-on-push.cjs +0 -40
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 /
|
|
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
|
|
82
|
-
'reviewed + security-passed + qa-walked
|
|
83
|
-
'
|
|
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,
|