@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.
- package/bin/wrxn.cjs +300 -0
- package/lib/agent-conformance.cjs +92 -0
- package/lib/ci-checks.cjs +296 -0
- package/lib/compass-coverage.cjs +53 -0
- package/lib/executor.cjs +7 -4
- package/lib/flow-status.cjs +87 -0
- 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 +40 -10
- 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 +59 -0
- package/payload/.claude/agents/devops.md +65 -0
- package/payload/.claude/agents/qa-walker.md +58 -0
- package/payload/.claude/agents/researcher.md +56 -0
- package/payload/.claude/agents/reviewer.md +60 -0
- package/payload/.claude/agents/security.md +59 -0
- 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 +101 -0
- package/payload/.claude/skills/qa-walk/SKILL.md +33 -0
- 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 -1
- package/payload/.synapse/pipeline +7 -6
- 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
7
8
|
|
|
8
9
|
const { init } = require('../lib/install.cjs');
|
|
9
10
|
const { update } = require('../lib/update.cjs');
|
|
@@ -11,10 +12,15 @@ const worktree = require('../lib/worktree.cjs');
|
|
|
11
12
|
const executor = require('../lib/executor.cjs');
|
|
12
13
|
const onboard = require('../lib/onboard.cjs');
|
|
13
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');
|
|
14
18
|
const brain = require('../lib/brain.cjs');
|
|
15
19
|
const statusline = require('../lib/statusline.cjs');
|
|
16
20
|
const { convert } = require('../lib/convert.cjs');
|
|
17
21
|
const { ingest } = require('../lib/ingest.cjs');
|
|
22
|
+
const { flowStatus } = require('../lib/flow-status.cjs');
|
|
23
|
+
const ciChecks = require('../lib/ci-checks.cjs');
|
|
18
24
|
|
|
19
25
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
20
26
|
|
|
@@ -23,6 +29,12 @@ function version() {
|
|
|
23
29
|
return pkg.version;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
// Escape a dynamic fragment before embedding it in a RegExp. Slice ids derive from the prd slug and
|
|
33
|
+
// issue filenames, so an unescaped metachar would otherwise build an invalid pattern (crash / ReDoS).
|
|
34
|
+
function escapeRegExp(s) {
|
|
35
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
function parseArgs(argv) {
|
|
27
39
|
const args = { _: [], flags: {} };
|
|
28
40
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -39,6 +51,14 @@ function parseArgs(argv) {
|
|
|
39
51
|
args.flags.root = argv[++i];
|
|
40
52
|
} else if (a === '--base') {
|
|
41
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];
|
|
42
62
|
} else if (a === '--path') {
|
|
43
63
|
args.flags.path = argv[++i];
|
|
44
64
|
} else if (a === '--executor') {
|
|
@@ -114,6 +134,22 @@ Usage:
|
|
|
114
134
|
list print all registered connections (agent-readable JSON)
|
|
115
135
|
get <name> print one connection by name
|
|
116
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
|
+
|
|
117
153
|
wrxn brain query "<q>" [--json] [--limit <n>] [--type <prose|code|NodeType>] [--neighbors] [--root <dir>]
|
|
118
154
|
ask the warm Brain (recon-wrxn's code+prose graph) from the terminal.
|
|
119
155
|
WHOLE-BRAIN by default. Discovers the live serve door via
|
|
@@ -150,6 +186,31 @@ Usage:
|
|
|
150
186
|
aios-intake.md (the deterministic half of the onboard skill;
|
|
151
187
|
workspace installs only). Idempotent.
|
|
152
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
|
+
|
|
206
|
+
wrxn flow status <prd> [--root <dir>]
|
|
207
|
+
print the flow-status board for a PRD's issue set. Reads issues
|
|
208
|
+
from .scratch/<prd>/issues/, detects gate artifacts (green commit
|
|
209
|
+
via git log, review-<id>.md, security-<id>.md, walk-findings-<id>.md),
|
|
210
|
+
and prints per-slice gate progress (build/review/sec/qa) plus state
|
|
211
|
+
(done | in-progress | stalled | queued). No separate state store —
|
|
212
|
+
derives from the durable artifacts only.
|
|
213
|
+
|
|
153
214
|
Profiles: --project (default, the dev pipeline + intelligence + enforcement) |
|
|
154
215
|
--workspace (adds the operator layer: onboard/audit/level-up + intake + decisions log +
|
|
155
216
|
connections registry).`;
|
|
@@ -227,6 +288,14 @@ async function main(argv) {
|
|
|
227
288
|
if (report.migrationsRan && report.migrationsRan.length) {
|
|
228
289
|
process.stdout.write(`migrations applied: ${report.migrationsRan.join(', ')}\n`);
|
|
229
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
|
+
}
|
|
230
299
|
return 0;
|
|
231
300
|
}
|
|
232
301
|
|
|
@@ -424,6 +493,61 @@ async function main(argv) {
|
|
|
424
493
|
}
|
|
425
494
|
}
|
|
426
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
|
+
|
|
427
551
|
if (cmd === 'brain') {
|
|
428
552
|
const sub = args._[1];
|
|
429
553
|
if (sub !== 'query') {
|
|
@@ -490,6 +614,182 @@ async function main(argv) {
|
|
|
490
614
|
return 0;
|
|
491
615
|
}
|
|
492
616
|
|
|
617
|
+
if (cmd === 'flow') {
|
|
618
|
+
const sub = args._[1];
|
|
619
|
+
if (sub === 'status') {
|
|
620
|
+
const prd = args._[2];
|
|
621
|
+
if (!prd) {
|
|
622
|
+
process.stderr.write('wrxn: flow status requires <prd>\n');
|
|
623
|
+
return 2;
|
|
624
|
+
}
|
|
625
|
+
const root = path.resolve(args.flags.root || process.cwd());
|
|
626
|
+
// S2: prd flows into a filesystem path — keep it a plain dir name contained within
|
|
627
|
+
// <root>/.scratch/. Reject "..", absolute paths, and anything that resolves outside .scratch/.
|
|
628
|
+
const scratchRoot = path.join(root, '.scratch');
|
|
629
|
+
const issuesDir = path.join(scratchRoot, prd, 'issues');
|
|
630
|
+
if (prd.includes('..') || path.isAbsolute(prd)
|
|
631
|
+
|| !path.resolve(issuesDir).startsWith(path.resolve(scratchRoot) + path.sep)) {
|
|
632
|
+
process.stderr.write(`wrxn: invalid prd "${prd}" — must be a directory name under .scratch/\n`);
|
|
633
|
+
return 2;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Read issue files sorted by filename (numeric ordering preserved by filename prefix)
|
|
637
|
+
let issueFiles;
|
|
638
|
+
try {
|
|
639
|
+
issueFiles = fs.readdirSync(issuesDir).filter((f) => f.endsWith('.md')).sort();
|
|
640
|
+
} catch (err) {
|
|
641
|
+
process.stderr.write(`wrxn: cannot read issues from ${issuesDir}: ${err.message}\n`);
|
|
642
|
+
return 2;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Derive the id prefix from the PRD slug (e.g. "flow-redesign" → "flow", "myprd" → "myprd")
|
|
646
|
+
const prdPrefix = prd.split('-')[0];
|
|
647
|
+
|
|
648
|
+
// Parse each issue file: id from filename, title + blockedBy from content
|
|
649
|
+
const issues = issueFiles.map((f) => {
|
|
650
|
+
const stem = f.replace(/\.md$/, '');
|
|
651
|
+
const numMatch = stem.match(/^(\d+)/);
|
|
652
|
+
const num = numMatch ? numMatch[1] : stem;
|
|
653
|
+
const id = `${prdPrefix}-${num}`;
|
|
654
|
+
let title = stem;
|
|
655
|
+
const blockedBy = [];
|
|
656
|
+
try {
|
|
657
|
+
const text = fs.readFileSync(path.join(issuesDir, f), 'utf8');
|
|
658
|
+
// First heading: # N — Title (em-dash or hyphen)
|
|
659
|
+
const titleMatch = text.match(/^#\s+\d+\s*[—\-]\s*(.+)/m);
|
|
660
|
+
if (titleMatch) title = titleMatch[1].trim();
|
|
661
|
+
// ## Blocked by section — bullet items
|
|
662
|
+
let inBlocked = false;
|
|
663
|
+
for (const line of text.split('\n')) {
|
|
664
|
+
if (/^##\s+/.test(line)) { inBlocked = /^##\s+blocked by/i.test(line); continue; }
|
|
665
|
+
if (inBlocked) {
|
|
666
|
+
const m = line.match(/^\s*-\s+(.+)$/);
|
|
667
|
+
if (m) {
|
|
668
|
+
const item = m[1].trim();
|
|
669
|
+
// B1: the "None — can start immediately." sentinel is not a real dependency.
|
|
670
|
+
if (/^none\b/i.test(item)) continue;
|
|
671
|
+
// B2: normalize a "02 (desc)." bullet to the canonical id "<prefix>-02" so it can be
|
|
672
|
+
// resolved against the done set. A bullet that is already an id is kept as-is.
|
|
673
|
+
const numM = item.match(/^(\d+)/);
|
|
674
|
+
blockedBy.push(numM ? `${prdPrefix}-${numM[1]}` : item);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} catch { /* id still usable without content */ }
|
|
679
|
+
return { id, title, blockedBy };
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// git log for greenCommit detection — fail-open (not a git repo is fine)
|
|
683
|
+
let gitLog = '';
|
|
684
|
+
try {
|
|
685
|
+
gitLog = execFileSync('git', ['-C', root, 'log', '--all', '--oneline'], {
|
|
686
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
687
|
+
});
|
|
688
|
+
} catch { /* not a repo, or git unavailable — all build gates stay pending */ }
|
|
689
|
+
|
|
690
|
+
// Look for an artifact file in the common locations an executor would write it
|
|
691
|
+
function findArtifactFile(name) {
|
|
692
|
+
const candidates = [
|
|
693
|
+
path.join(root, name),
|
|
694
|
+
path.join(root, '.scratch', prd, name),
|
|
695
|
+
path.join(root, '.claude', 'ai', 'output', name),
|
|
696
|
+
];
|
|
697
|
+
for (const c of candidates) {
|
|
698
|
+
try { if (fs.existsSync(c)) return c; } catch { /* skip */ }
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Build artifact map for flowStatus
|
|
704
|
+
const artifacts = {};
|
|
705
|
+
for (const issue of issues) {
|
|
706
|
+
const { id } = issue;
|
|
707
|
+
const entry = {};
|
|
708
|
+
// greenCommit: commit message containing [id] or the bare id word.
|
|
709
|
+
// S1: escape the id (+ guard construction) so a metachar id can never build an invalid RegExp.
|
|
710
|
+
const eid = escapeRegExp(id);
|
|
711
|
+
let re;
|
|
712
|
+
try { re = new RegExp(`\\[${eid}\\]|\\b${eid}\\b`); } catch { re = null; }
|
|
713
|
+
const commitLine = re ? gitLog.split('\n').find((l) => re.test(l)) : null;
|
|
714
|
+
if (commitLine) entry.greenCommit = commitLine.trim().split(/\s+/)[0];
|
|
715
|
+
// review marker
|
|
716
|
+
const reviewFile = findArtifactFile(`review-${id}.md`);
|
|
717
|
+
if (reviewFile) entry.reviewMarker = reviewFile;
|
|
718
|
+
// security report
|
|
719
|
+
const secFile = findArtifactFile(`security-${id}.md`);
|
|
720
|
+
if (secFile) entry.securityReport = secFile;
|
|
721
|
+
// walk findings (two common naming conventions)
|
|
722
|
+
const walkFile = findArtifactFile(`walk-findings-${id}.md`) || findArtifactFile(`walk-${id}.md`);
|
|
723
|
+
if (walkFile) entry.walkFindings = walkFile;
|
|
724
|
+
artifacts[id] = entry;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const board = flowStatus(issues, artifacts);
|
|
728
|
+
|
|
729
|
+
// Print readable board: id build✓ review· sec· qa· state
|
|
730
|
+
for (const slice of board) {
|
|
731
|
+
const g = slice.gates;
|
|
732
|
+
const cols = [
|
|
733
|
+
`build${g.build === 'done' ? '✓' : '·'}`,
|
|
734
|
+
`review${g.review === 'done' ? '✓' : '·'}`,
|
|
735
|
+
`sec${g.security === 'done' ? '✓' : '·'}`,
|
|
736
|
+
`qa${g.qa === 'done' ? '✓' : '·'}`,
|
|
737
|
+
];
|
|
738
|
+
process.stdout.write(`${slice.id.padEnd(16)} ${cols.join(' ')} ${slice.state}\n`);
|
|
739
|
+
}
|
|
740
|
+
return 0;
|
|
741
|
+
}
|
|
742
|
+
process.stderr.write(`wrxn: unknown flow subcommand "${sub || ''}"\n\n${USAGE}\n`);
|
|
743
|
+
return 2;
|
|
744
|
+
}
|
|
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
|
+
|
|
493
793
|
process.stderr.write(`wrxn: unknown command "${cmd}"\n\n${USAGE}\n`);
|
|
494
794
|
return 2;
|
|
495
795
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Agent-contract conformance (wrxn-kernel flow-redesign flow-02).
|
|
4
|
+
// A pure transform mirroring lib/executor.cjs: it confirms a native executor subagent definition
|
|
5
|
+
// (.claude/agents/<type>.md) is a faithful THIN WRAPPER of the dispatch contract for its type — it
|
|
6
|
+
// declares least-privilege tools, a model, and an output contract that EQUALS that type's
|
|
7
|
+
// reportSchema (EXECUTORS[type].required). The agent file carries no logic the harness doesn't
|
|
8
|
+
// already define, so validateReport's guarantees still hold by construction.
|
|
9
|
+
//
|
|
10
|
+
// An agent .md declares its output contract in a fenced ```output-contract block in the body — one
|
|
11
|
+
// required report field per line. validateAgentFile accepts EITHER the raw markdown (it parses the
|
|
12
|
+
// frontmatter + that block) OR a pre-parsed { tools, model, outputContract } object, so it is
|
|
13
|
+
// unit-testable without a live LLM.
|
|
14
|
+
|
|
15
|
+
const { EXECUTORS } = require('./executor.cjs');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an agent .md into { name, tools, model, outputContract }.
|
|
19
|
+
* - frontmatter `tools:` → comma-separated least-privilege allowlist
|
|
20
|
+
* - frontmatter `model:` → scalar
|
|
21
|
+
* - the fenced ```output-contract block → one report field per line
|
|
22
|
+
* Tolerant: a missing piece yields an empty value rather than throwing.
|
|
23
|
+
*/
|
|
24
|
+
function parseAgentFile(markdown) {
|
|
25
|
+
const text = String(markdown || '');
|
|
26
|
+
const fmEnd = text.startsWith('---') ? text.indexOf('\n---', 3) : -1;
|
|
27
|
+
const fm = fmEnd !== -1 ? text.slice(3, fmEnd) : '';
|
|
28
|
+
|
|
29
|
+
const scalar = (key) => {
|
|
30
|
+
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'm'));
|
|
31
|
+
return m ? m[1].trim().replace(/^["']|["']$/g, '') : '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const toolsRaw = scalar('tools');
|
|
35
|
+
const tools = toolsRaw
|
|
36
|
+
? toolsRaw.replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
37
|
+
: [];
|
|
38
|
+
|
|
39
|
+
// The fenced ```output-contract block (info-string `output-contract`), one field per line; a
|
|
40
|
+
// leading bullet marker is tolerated so the block can read as a list.
|
|
41
|
+
const block = text.match(/```output-contract[^\n]*\n([\s\S]*?)```/);
|
|
42
|
+
const outputContract = block
|
|
43
|
+
? block[1].split('\n').map((l) => l.replace(/^\s*[-*]\s*/, '').trim()).filter(Boolean)
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
return { name: scalar('name'), tools, model: scalar('model'), outputContract };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Order-independent set-equality over two string arrays (duplicate-safe). */
|
|
50
|
+
function sameSet(a, b) {
|
|
51
|
+
const sa = new Set(a);
|
|
52
|
+
const sb = new Set(b);
|
|
53
|
+
if (sa.size !== sb.size) return false;
|
|
54
|
+
for (const x of sa) if (!sb.has(x)) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that an executor agent definition conforms to EXECUTORS[type]:
|
|
60
|
+
* - declares a non-empty `tools` allowlist (least-privilege; presence, not a frozen list —
|
|
61
|
+
* new MCP tools are valid, per the write-an-agent doctrine),
|
|
62
|
+
* - declares a `model`,
|
|
63
|
+
* - its declared output contract EQUALS that type's reportSchema (EXECUTORS[type].required),
|
|
64
|
+
* - the type is known.
|
|
65
|
+
* `agentDef` may be the raw agent markdown (string) or a pre-parsed
|
|
66
|
+
* { tools, model, outputContract } object. Returns { ok, errors } (mirrors validateReport).
|
|
67
|
+
*/
|
|
68
|
+
function validateAgentFile(agentDef, type) {
|
|
69
|
+
const def = EXECUTORS[type];
|
|
70
|
+
if (!def) return { ok: false, errors: [`unknown executor type: ${type}`] };
|
|
71
|
+
|
|
72
|
+
const parsed = typeof agentDef === 'string' ? parseAgentFile(agentDef) : (agentDef || {});
|
|
73
|
+
const errors = [];
|
|
74
|
+
|
|
75
|
+
const tools = Array.isArray(parsed.tools) ? parsed.tools : [];
|
|
76
|
+
if (tools.length === 0) errors.push('agent declares no tools (a least-privilege allowlist is required)');
|
|
77
|
+
|
|
78
|
+
if (!parsed.model || typeof parsed.model !== 'string' || !parsed.model.trim()) {
|
|
79
|
+
errors.push('agent declares no model');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const declared = Array.isArray(parsed.outputContract) ? parsed.outputContract : [];
|
|
83
|
+
if (!sameSet(declared, def.required)) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`output contract ${JSON.stringify(declared)} does not equal the ${type} reportSchema ${JSON.stringify(def.required)}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: errors.length === 0, errors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { validateAgentFile, parseAgentFile };
|