@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
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 };