verity-framework 0.1.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/commands/verity/architect.md +58 -0
  4. package/commands/verity/build.md +61 -0
  5. package/commands/verity/docs.md +37 -0
  6. package/commands/verity/golive.md +32 -0
  7. package/commands/verity/map.md +22 -0
  8. package/commands/verity/plan.md +67 -0
  9. package/commands/verity/review.md +50 -0
  10. package/commands/verity/security.md +36 -0
  11. package/commands/verity/ship.md +67 -0
  12. package/commands/verity/sre.md +31 -0
  13. package/commands/verity/test.md +29 -0
  14. package/commands/verity/verify.md +31 -0
  15. package/commands/verity/vision.md +55 -0
  16. package/package.json +31 -0
  17. package/verity/bin/lib/adr.cjs +67 -0
  18. package/verity/bin/lib/catalog.cjs +81 -0
  19. package/verity/bin/lib/config.cjs +119 -0
  20. package/verity/bin/lib/contract.cjs +57 -0
  21. package/verity/bin/lib/core.cjs +63 -0
  22. package/verity/bin/lib/golive.cjs +49 -0
  23. package/verity/bin/lib/handoff.cjs +72 -0
  24. package/verity/bin/lib/identity.cjs +112 -0
  25. package/verity/bin/lib/install.cjs +109 -0
  26. package/verity/bin/lib/ledger.cjs +244 -0
  27. package/verity/bin/lib/map.cjs +77 -0
  28. package/verity/bin/lib/recovery.cjs +37 -0
  29. package/verity/bin/lib/release.cjs +131 -0
  30. package/verity/bin/lib/review.cjs +74 -0
  31. package/verity/bin/lib/scaffold.cjs +66 -0
  32. package/verity/bin/lib/security.cjs +44 -0
  33. package/verity/bin/lib/smoke.cjs +170 -0
  34. package/verity/bin/lib/stage.cjs +180 -0
  35. package/verity/bin/lib/status.cjs +117 -0
  36. package/verity/bin/verity.cjs +190 -0
  37. package/verity/design-guides/contracts-first.md +32 -0
  38. package/verity/design-guides/features/helper-bot.md +61 -0
  39. package/verity/design-guides/stack-and-topology.md +38 -0
  40. package/verity/templates/LICENSE.tmpl +21 -0
  41. package/verity/templates/README.md.tmpl +14 -0
  42. package/verity/templates/STATUS.md.tmpl +27 -0
  43. package/verity/templates/adr.md.tmpl +21 -0
  44. package/verity/templates/bug_report.yml.tmpl +44 -0
  45. package/verity/templates/ci.yml.tmpl +36 -0
  46. package/verity/templates/contract.md.tmpl +21 -0
  47. package/verity/templates/gitignore.tmpl +9 -0
  48. package/verity/templates/handoff-brief.md.tmpl +32 -0
  49. package/verity/templates/handoff-readme.md.tmpl +21 -0
  50. package/verity/templates/recovery-plan.md.tmpl +29 -0
  51. package/verity/templates/security-invariants.md.tmpl +14 -0
  52. package/verity/templates/smoke.json.tmpl +21 -0
  53. package/verity/templates/stage.md.tmpl +28 -0
@@ -0,0 +1,109 @@
1
+ // Verity adapter / installer — the Runtime Adapter layer (framework-spec.md §4b).
2
+ // Same role-command CONTENT, transformed into each harness's format + install
3
+ // location. Claude Code is the reference harness; OpenCode is the second adapter.
4
+ // Capability differences (no Task sub-agents / no hooks on OpenCode) are handled by
5
+ // the commands' own "implement inline" fallback — the content already degrades.
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const PKG_ROOT = path.join(__dirname, '..', '..', '..');
11
+
12
+ function commandFiles(srcCommands) {
13
+ return fs.readdirSync(srcCommands).filter((n) => n.endsWith('.md'));
14
+ }
15
+
16
+ function copyInternals(target) {
17
+ fs.cpSync(path.join(PKG_ROOT, 'verity'), path.join(target, 'verity'), { recursive: true });
18
+ }
19
+
20
+ function claudeDir(opts) {
21
+ return opts.target || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
22
+ }
23
+
24
+ function installClaude(opts = {}) {
25
+ const target = claudeDir(opts);
26
+ const installed = [];
27
+
28
+ // 1. Role command files → <target>/commands/verity/ (Claude Code uses them as-is)
29
+ const srcCommands = path.join(PKG_ROOT, 'commands', 'verity');
30
+ const destCommands = path.join(target, 'commands', 'verity');
31
+ fs.mkdirSync(destCommands, { recursive: true });
32
+ for (const name of commandFiles(srcCommands)) {
33
+ fs.copyFileSync(path.join(srcCommands, name), path.join(destCommands, name));
34
+ installed.push(path.join('commands', 'verity', name));
35
+ }
36
+
37
+ // 2. Engine internals → <target>/verity/ (self-contained fallback for the CLI)
38
+ copyInternals(target);
39
+ installed.push('verity/');
40
+
41
+ return { harness: 'claude', target, installed };
42
+ }
43
+
44
+ // --- OpenCode adapter ---
45
+
46
+ function openCodeDir(opts) {
47
+ return (
48
+ opts.target || process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode')
49
+ );
50
+ }
51
+
52
+ // Transform a Claude command .md into OpenCode's command format:
53
+ // - frontmatter reduced to `description:` (OpenCode's per-command field; the
54
+ // Claude-only `allowed-tools` allowlist + `name` are dropped — OpenCode manages
55
+ // permissions globally and derives the command id from the filename)
56
+ // - the Claude-specific CLI fallback path is rewritten to the OpenCode config dir
57
+ function transformForOpenCode(content) {
58
+ const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
59
+ if (!m) {
60
+ return content;
61
+ }
62
+ const description = (m[1].match(/^description:\s*(.+)$/m) || [])[1] || '';
63
+ const body = m[2].replace(
64
+ /\$HOME\/\.claude\/verity/g,
65
+ '${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}/verity',
66
+ );
67
+ return `---\ndescription: ${description}\n---\n${body}`;
68
+ }
69
+
70
+ function installOpenCode(opts = {}) {
71
+ const target = openCodeDir(opts);
72
+ const installed = [];
73
+
74
+ // Role commands → <target>/command/, flattened to verity-<name>.md (invoked /verity-<name>)
75
+ const srcCommands = path.join(PKG_ROOT, 'commands', 'verity');
76
+ const destCommands = path.join(target, 'command');
77
+ fs.mkdirSync(destCommands, { recursive: true });
78
+ for (const name of commandFiles(srcCommands)) {
79
+ const out = `verity-${name}`;
80
+ const transformed = transformForOpenCode(fs.readFileSync(path.join(srcCommands, name), 'utf8'));
81
+ fs.writeFileSync(path.join(destCommands, out), transformed);
82
+ installed.push(path.join('command', out));
83
+ }
84
+
85
+ copyInternals(target);
86
+ installed.push('verity/');
87
+
88
+ return { harness: 'opencode', target, installed };
89
+ }
90
+
91
+ function dispatch(_args, flags) {
92
+ if (flags.opencode) {
93
+ return installOpenCode({ target: flags.target });
94
+ }
95
+ if (flags.codex || flags.gemini) {
96
+ throw new Error('only the claude and opencode adapters are implemented so far');
97
+ }
98
+ return installClaude({ target: flags.target });
99
+ }
100
+
101
+ module.exports = {
102
+ installClaude,
103
+ installOpenCode,
104
+ transformForOpenCode,
105
+ openCodeDir,
106
+ dispatch,
107
+ claudeDir,
108
+ PKG_ROOT,
109
+ };
@@ -0,0 +1,244 @@
1
+ // Ledger — the state-derivation engine (framework-spec.md §5). READ-ONLY w.r.t.
2
+ // state: it NEVER writes a state file. Integration state is DERIVED by correlating
3
+ // local stage specs (intent) with GitHub issues/PRs/tags (progress). A transition
4
+ // IS a GitHub act, so there is nothing to keep in sync and nothing to conflict on.
5
+ //
6
+ // The GitHub snapshot is injectable (opts.snapshot) so derivation is unit-testable
7
+ // without network; the default shells out to `gh` + `git` best-effort (offline →
8
+ // empty snapshot → everything reads as "planned", which is honest).
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const { execFileSync } = require('node:child_process');
12
+
13
+ function stageDir(cwd) {
14
+ return path.join(cwd, 'stage-instructions');
15
+ }
16
+
17
+ function stageNum(name) {
18
+ const m = name.match(/^stage-(\d+)-/);
19
+ return m ? Number(m[1]) : 0;
20
+ }
21
+
22
+ function parseStageFile(cwd, name) {
23
+ const text = fs.readFileSync(path.join(stageDir(cwd), name), 'utf8');
24
+ const title = (text.match(/^#\s+Stage\s+\d+:\s+(.+)$/m) || [])[1] || name;
25
+ const type = (text.match(/\*\*Type:\*\*\s*(\w+)/) || [])[1] || 'feature';
26
+ const depRaw = ((text.match(/\*\*Depends on:\*\*\s*(.+)$/m) || [])[1] || 'none').trim();
27
+ const dependsOn =
28
+ depRaw.toLowerCase() === 'none'
29
+ ? []
30
+ : depRaw
31
+ .split(',')
32
+ .map((s) => Number(s.trim()))
33
+ .filter((n) => Number.isFinite(n) && n > 0);
34
+ return { number: stageNum(name), title: title.trim(), type, dependsOn, file: name };
35
+ }
36
+
37
+ function readStages(cwd) {
38
+ const dir = stageDir(cwd);
39
+ if (!fs.existsSync(dir)) {
40
+ return [];
41
+ }
42
+ return fs
43
+ .readdirSync(dir)
44
+ .filter((n) => n.endsWith('.md'))
45
+ .map((n) => parseStageFile(cwd, n))
46
+ .sort((a, b) => a.number - b.number);
47
+ }
48
+
49
+ function matchesStage(title, number) {
50
+ return typeof title === 'string' && title.startsWith(`[stage ${number}]`);
51
+ }
52
+
53
+ function prMatches(pr, number) {
54
+ return (
55
+ matchesStage(pr.title, number) ||
56
+ (typeof pr.headRefName === 'string' && pr.headRefName.startsWith(`feat/stage-${number}-`))
57
+ );
58
+ }
59
+
60
+ // Status precedence: merged (PR merged or issue closed) > in-review/building (PR open,
61
+ // by CI) > claimed (issue assigned) > planned.
62
+ function deriveStatus(stage, snapshot) {
63
+ const issue = (snapshot.issues || []).find((i) => matchesStage(i.title, stage.number));
64
+ const pr = (snapshot.prs || []).find((p) => prMatches(p, stage.number));
65
+ const claimed = !!(issue && Array.isArray(issue.assignees) && issue.assignees.length > 0);
66
+
67
+ let status = 'planned';
68
+ if (claimed) {
69
+ status = 'claimed';
70
+ }
71
+ if (issue && issue.state === 'CLOSED') {
72
+ status = 'merged';
73
+ }
74
+ if (pr) {
75
+ if (pr.state === 'MERGED') {
76
+ status = 'merged';
77
+ } else if (pr.state === 'OPEN') {
78
+ status = pr.ciGreen ? 'in-review' : 'building';
79
+ }
80
+ }
81
+ return {
82
+ number: stage.number,
83
+ title: stage.title,
84
+ type: stage.type,
85
+ dependsOn: stage.dependsOn,
86
+ status,
87
+ issue: issue ? issue.number : null,
88
+ pr: pr ? pr.number : null,
89
+ };
90
+ }
91
+
92
+ function unblocked(derived) {
93
+ const merged = new Set(derived.filter((s) => s.status === 'merged').map((s) => s.number));
94
+ return derived
95
+ .filter((s) => s.status !== 'merged' && s.dependsOn.every((d) => merged.has(d)))
96
+ .map((s) => s.number);
97
+ }
98
+
99
+ function semverKey(tag) {
100
+ return tag
101
+ .replace(/^v/, '')
102
+ .split('.')
103
+ .map((x) => Number.parseInt(x, 10) || 0);
104
+ }
105
+
106
+ function latestTag(tags) {
107
+ if (!tags || tags.length === 0) {
108
+ return null;
109
+ }
110
+ return [...tags]
111
+ .sort((a, b) => {
112
+ const ka = semverKey(a);
113
+ const kb = semverKey(b);
114
+ for (let i = 0; i < 3; i += 1) {
115
+ if ((ka[i] || 0) !== (kb[i] || 0)) {
116
+ return (ka[i] || 0) - (kb[i] || 0);
117
+ }
118
+ }
119
+ return 0;
120
+ })
121
+ .pop();
122
+ }
123
+
124
+ function project(cwd, opts = {}) {
125
+ const snapshot = opts.snapshot || fetchSnapshot(cwd);
126
+ const stages = readStages(cwd).map((s) => deriveStatus(s, snapshot));
127
+ return {
128
+ online: snapshot.online !== false,
129
+ release: latestTag(snapshot.tags),
130
+ stages,
131
+ next: unblocked(stages),
132
+ };
133
+ }
134
+
135
+ function summarize(proj) {
136
+ const byStatus = {};
137
+ for (const s of proj.stages) {
138
+ byStatus[s.status] = (byStatus[s.status] || 0) + 1;
139
+ }
140
+ return {
141
+ total: proj.stages.length,
142
+ byStatus,
143
+ release: proj.release,
144
+ next: proj.next,
145
+ online: proj.online,
146
+ raw: `${proj.stages.length} stages · release ${proj.release || '(none)'} · next ${JSON.stringify(proj.next)}`,
147
+ };
148
+ }
149
+
150
+ // --- default GitHub source (best-effort; never throws) ---
151
+ function ghJson(args) {
152
+ try {
153
+ return JSON.parse(
154
+ execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
155
+ );
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function rollupGreen(rollup) {
162
+ if (!Array.isArray(rollup) || rollup.length === 0) {
163
+ return false;
164
+ }
165
+ const ok = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
166
+ return rollup.every((c) => ok.has(c.conclusion || c.state));
167
+ }
168
+
169
+ function fetchSnapshot(cwd) {
170
+ const issues =
171
+ ghJson([
172
+ 'issue',
173
+ 'list',
174
+ '--state',
175
+ 'all',
176
+ '--limit',
177
+ '300',
178
+ '--json',
179
+ 'number,title,state,labels,assignees',
180
+ ]) || [];
181
+ const prsRaw =
182
+ ghJson([
183
+ 'pr',
184
+ 'list',
185
+ '--state',
186
+ 'all',
187
+ '--limit',
188
+ '300',
189
+ '--json',
190
+ 'number,title,state,headRefName,statusCheckRollup',
191
+ ]) || [];
192
+ const prs = prsRaw.map((p) => ({ ...p, ciGreen: rollupGreen(p.statusCheckRollup) }));
193
+ let tags = [];
194
+ try {
195
+ tags = execFileSync('git', ['-C', cwd, 'tag'], {
196
+ encoding: 'utf8',
197
+ stdio: ['ignore', 'pipe', 'ignore'],
198
+ })
199
+ .split('\n')
200
+ .filter(Boolean);
201
+ } catch {
202
+ tags = [];
203
+ }
204
+ const online = ghJson(['repo', 'view', '--json', 'name']) !== null;
205
+ return { issues, prs, tags, online };
206
+ }
207
+
208
+ function dispatch(args, flags) {
209
+ const cwd = flags.cwd || process.cwd();
210
+ const verb = args[0] || 'view';
211
+ const proj = project(cwd, {});
212
+ if (verb === 'view') {
213
+ return proj;
214
+ }
215
+ if (verb === 'next') {
216
+ return { next: proj.next, raw: JSON.stringify(proj.next) };
217
+ }
218
+ if (verb === 'summary') {
219
+ return summarize(proj);
220
+ }
221
+ if (verb === 'graph') {
222
+ return { graph: proj.stages.map((s) => ({ number: s.number, dependsOn: s.dependsOn })) };
223
+ }
224
+ if (verb === 'stage') {
225
+ const n = Number(args[1]);
226
+ const found = proj.stages.find((s) => s.number === n);
227
+ if (!found) {
228
+ throw new Error(`no stage ${args[1]}`);
229
+ }
230
+ return found;
231
+ }
232
+ throw new Error(`unknown state verb: ${verb} — use view|next|stage|summary|graph`);
233
+ }
234
+
235
+ module.exports = {
236
+ readStages,
237
+ parseStageFile,
238
+ deriveStatus,
239
+ unblocked,
240
+ latestTag,
241
+ project,
242
+ summarize,
243
+ dispatch,
244
+ };
@@ -0,0 +1,77 @@
1
+ // Codebase Mapper (framework-spec.md §6, Role 14) — on-demand, generated code-STRUCTURE
2
+ // diagram (distinct from the Planner's Gantt/schedule). Walks the directory tree and
3
+ // emits a Mermaid graph to codebase-map.md. Never hand-maintained — regenerate anytime.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const IGNORE = new Set(['node_modules', '.git', 'dist', '.verity', '.verity-cache']);
8
+
9
+ function build(cwd, maxDepth) {
10
+ const rootName = path.basename(path.resolve(cwd)) || 'root';
11
+ const nodes = [];
12
+ const edges = [];
13
+ const ids = new Map();
14
+ let counter = 0;
15
+
16
+ const idFor = (key, label) => {
17
+ if (!ids.has(key)) {
18
+ const id = `n${counter}`;
19
+ counter += 1;
20
+ ids.set(key, id);
21
+ nodes.push({ id, label });
22
+ }
23
+ return ids.get(key);
24
+ };
25
+
26
+ const walk = (dir, parentKey, parentId, depth) => {
27
+ if (depth >= maxDepth) {
28
+ return;
29
+ }
30
+ let entries = [];
31
+ try {
32
+ entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+ const dirs = entries
37
+ .filter((e) => e.isDirectory() && !IGNORE.has(e.name))
38
+ .sort((a, b) => a.name.localeCompare(b.name));
39
+ for (const e of dirs) {
40
+ const childKey = `${parentKey}/${e.name}`;
41
+ const childId = idFor(childKey, e.name);
42
+ edges.push([parentId, childId]);
43
+ walk(path.join(dir, e.name), childKey, childId, depth + 1);
44
+ }
45
+ };
46
+
47
+ const rootId = idFor(rootName, rootName);
48
+ walk(cwd, rootName, rootId, 0);
49
+ return { nodes, edges };
50
+ }
51
+
52
+ function render({ nodes, edges }) {
53
+ const lines = ['# Codebase Map', '', '```mermaid', 'graph TD'];
54
+ for (const n of nodes) {
55
+ lines.push(` ${n.id}["${n.label}"]`);
56
+ }
57
+ for (const [from, to] of edges) {
58
+ lines.push(` ${from} --> ${to}`);
59
+ }
60
+ lines.push('```', '');
61
+ return lines.join('\n');
62
+ }
63
+
64
+ function generate(cwd, opts = {}) {
65
+ const maxDepth = Number(opts.depth) > 0 ? Number(opts.depth) : 2;
66
+ const graph = build(cwd, maxDepth);
67
+ const out = path.join(cwd, 'codebase-map.md');
68
+ fs.writeFileSync(out, render(graph));
69
+ return { path: out, nodes: graph.nodes.length, edges: graph.edges.length };
70
+ }
71
+
72
+ function dispatch(args, flags) {
73
+ const cwd = flags.cwd || process.cwd();
74
+ return generate(cwd, { depth: flags.depth });
75
+ }
76
+
77
+ module.exports = { build, render, generate, dispatch };
@@ -0,0 +1,37 @@
1
+ // SRE (framework-spec.md §6, Role 10) — recovery-plan.md scaffold. Steady-state
2
+ // readiness (rollback/restore drills, backup contract, intermittent-env, secret
3
+ // rotation), distinct from the Operator's deploy act.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const TEMPLATE = path.join(__dirname, '..', '..', 'templates', 'recovery-plan.md.tmpl');
8
+
9
+ function planPath(cwd) {
10
+ return path.join(cwd, 'recovery-plan.md');
11
+ }
12
+
13
+ function init(cwd, opts = {}) {
14
+ const p = planPath(cwd);
15
+ if (fs.existsSync(p) && !opts.force) {
16
+ return { created: false, path: p };
17
+ }
18
+ fs.writeFileSync(p, fs.readFileSync(TEMPLATE, 'utf8'));
19
+ return { created: true, path: p };
20
+ }
21
+
22
+ function dispatch(args, flags) {
23
+ const cwd = flags.cwd || process.cwd();
24
+ const verb = args[0];
25
+ if (verb === 'init') {
26
+ return init(cwd, { force: Boolean(flags.force) });
27
+ }
28
+ if (verb === 'show') {
29
+ if (!fs.existsSync(planPath(cwd))) {
30
+ throw new Error('no recovery-plan.md — run `verity recovery init`');
31
+ }
32
+ return { path: planPath(cwd), content: fs.readFileSync(planPath(cwd), 'utf8') };
33
+ }
34
+ throw new Error(`unknown recovery verb: ${verb || '(none)'} — use init|show`);
35
+ }
36
+
37
+ module.exports = { planPath, init, dispatch };
@@ -0,0 +1,131 @@
1
+ // Release/Deploy Operator — release half (framework-spec.md §6, Role 7 / Shipyard).
2
+ // version DERIVED from the latest tag (so the binary can't lie about its version) +
3
+ // changelog auto-generated from Conventional Commits. Tags/commits are injectable
4
+ // (opts.tags / opts.commits) so the logic is unit-testable without git.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { execFileSync } = require('node:child_process');
8
+
9
+ const ledger = require('./ledger.cjs');
10
+
11
+ function git(cwd, args) {
12
+ try {
13
+ return execFileSync('git', ['-C', cwd, ...args], {
14
+ encoding: 'utf8',
15
+ stdio: ['ignore', 'pipe', 'ignore'],
16
+ });
17
+ } catch {
18
+ return '';
19
+ }
20
+ }
21
+
22
+ function gitTags(cwd) {
23
+ return git(cwd, ['tag']).split('\n').filter(Boolean);
24
+ }
25
+
26
+ function commitsSince(cwd, tag) {
27
+ const range = tag ? `${tag}..HEAD` : 'HEAD';
28
+ return git(cwd, ['log', range, '--pretty=%s']).split('\n').filter(Boolean);
29
+ }
30
+
31
+ function parseVersion(tag) {
32
+ return tag
33
+ .replace(/^v/, '')
34
+ .split('.')
35
+ .map((x) => Number.parseInt(x, 10) || 0);
36
+ }
37
+
38
+ function nextVersion(currentTag, bump) {
39
+ const [maj, min, pat] = currentTag ? parseVersion(currentTag) : [0, 0, 0];
40
+ if (bump === 'major') {
41
+ return `${(maj || 0) + 1}.0.0`;
42
+ }
43
+ if (bump === 'minor') {
44
+ return `${maj || 0}.${(min || 0) + 1}.0`;
45
+ }
46
+ return `${maj || 0}.${min || 0}.${(pat || 0) + 1}`;
47
+ }
48
+
49
+ const CONVENTIONAL = /^(\w+)(\([^)]*\))?(!)?:\s*(.+)$/;
50
+
51
+ function changelogFrom(commits, version) {
52
+ const groups = { feat: [], fix: [], chore: [], other: [] };
53
+ for (const c of commits) {
54
+ const m = c.match(CONVENTIONAL);
55
+ if (m && groups[m[1]]) {
56
+ groups[m[1]].push(m[4]);
57
+ } else {
58
+ groups.other.push(c);
59
+ }
60
+ }
61
+ const lines = [`## ${version}`, ''];
62
+ const section = (title, arr) => {
63
+ if (arr.length > 0) {
64
+ lines.push(`### ${title}`);
65
+ for (const s of arr) {
66
+ lines.push(`- ${s}`);
67
+ }
68
+ lines.push('');
69
+ }
70
+ };
71
+ section('Features', groups.feat);
72
+ section('Fixes', groups.fix);
73
+ section('Chores', groups.chore);
74
+ section('Other', groups.other);
75
+ return lines.join('\n').trim();
76
+ }
77
+
78
+ function prependChangelog(cwd, section) {
79
+ const p = path.join(cwd, 'CHANGELOG.md');
80
+ const header = '# Changelog';
81
+ const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8').replace(header, '').trim() : '';
82
+ const body = `${header}\n\n${section}\n\n${existing}`.trim();
83
+ fs.writeFileSync(p, `${body}\n`);
84
+ }
85
+
86
+ function run(cmd, args) {
87
+ execFileSync(cmd, args, { stdio: 'inherit' });
88
+ }
89
+
90
+ function cut(cwd, opts = {}) {
91
+ const tags = opts.tags || gitTags(cwd);
92
+ const previous = ledger.latestTag(tags);
93
+ const version = nextVersion(previous, opts.bump || 'patch');
94
+ const tag = `v${version}`;
95
+ const commits = opts.commits || commitsSince(cwd, previous);
96
+ const changelog = changelogFrom(commits, version);
97
+ if (!opts.dryRun) {
98
+ prependChangelog(cwd, changelog);
99
+ run('git', ['-C', cwd, 'tag', tag]);
100
+ if (opts.push !== false) {
101
+ run('git', ['-C', cwd, 'push', 'origin', tag]);
102
+ }
103
+ }
104
+ return { version, tag, previous, changelog, commitCount: commits.length, applied: !opts.dryRun };
105
+ }
106
+
107
+ function current(cwd) {
108
+ const latest = ledger.latestTag(gitTags(cwd));
109
+ return { latest, version: latest ? latest.replace(/^v/, '') : null, raw: latest || '' };
110
+ }
111
+
112
+ function dispatch(args, flags) {
113
+ const cwd = flags.cwd || process.cwd();
114
+ const verb = args[0];
115
+ if (verb === 'current') {
116
+ return current(cwd);
117
+ }
118
+ if (verb === 'changelog') {
119
+ return cut(cwd, { bump: flags.bump, dryRun: true });
120
+ }
121
+ if (verb === 'cut') {
122
+ return cut(cwd, {
123
+ bump: flags.bump,
124
+ dryRun: Boolean(flags['dry-run']),
125
+ push: !flags['no-push'],
126
+ });
127
+ }
128
+ throw new Error(`unknown release verb: ${verb || '(none)'} — use cut|changelog|current`);
129
+ }
130
+
131
+ module.exports = { nextVersion, changelogFrom, cut, current, dispatch };
@@ -0,0 +1,74 @@
1
+ // Reviewer/Integrator (framework-spec.md §6, Role 6). The per-PR gate, adversarial
2
+ // to the builder. With branch protection often unavailable, the Reviewer's
3
+ // approval + confirmed-green CI IS the integration gate — so `merge` REFUSES on red.
4
+ const { execFileSync } = require('node:child_process');
5
+
6
+ const stage = require('./stage.cjs');
7
+ const contract = require('./contract.cjs');
8
+ const security = require('./security.cjs');
9
+
10
+ // The checklist is PRE-DECLARED (acceptance conditions from the stage spec +
11
+ // contracts to verify conformance against). The Reviewer verifies each against the
12
+ // actual diff/source — never the PR description (the headline behavior).
13
+ function checklist(cwd, n) {
14
+ const invariants = security.read(cwd);
15
+ return {
16
+ stage: n,
17
+ acceptance: stage.acceptanceText(cwd, n),
18
+ contracts: contract.list(cwd).contracts,
19
+ securityInvariants: invariants || '(none defined — run `verity security init`)',
20
+ instructions:
21
+ 'Verify each item against the ACTUAL diff/source, not the PR description. Confirm CI is green first.',
22
+ };
23
+ }
24
+
25
+ // The gate, as a pure decision so it is unit-testable without network.
26
+ function canMerge(ciGreen) {
27
+ return ciGreen === true;
28
+ }
29
+
30
+ function ciGreenFor(pr, cwd) {
31
+ try {
32
+ const out = execFileSync('gh', ['pr', 'view', String(pr), '--json', 'statusCheckRollup'], {
33
+ encoding: 'utf8',
34
+ cwd,
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ });
37
+ const rollup = JSON.parse(out).statusCheckRollup || [];
38
+ const ok = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
39
+ return rollup.length > 0 && rollup.every((c) => ok.has(c.conclusion || c.state));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function merge(cwd, pr, flags) {
46
+ if (flags['dry-run']) {
47
+ return { pr, merged: false, dryRun: true };
48
+ }
49
+ const green = flags['assume-green'] ? true : ciGreenFor(pr, cwd);
50
+ if (!canMerge(green)) {
51
+ throw new Error(
52
+ `refusing to merge PR #${pr}: CI is not green (the gate, even when branch protection is unavailable)`,
53
+ );
54
+ }
55
+ execFileSync('gh', ['pr', 'merge', String(pr), '--squash', '--delete-branch'], {
56
+ stdio: 'inherit',
57
+ cwd,
58
+ });
59
+ return { pr, merged: true };
60
+ }
61
+
62
+ function dispatch(args, flags) {
63
+ const cwd = flags.cwd || process.cwd();
64
+ const verb = args[0];
65
+ if (verb === 'checklist') {
66
+ return checklist(cwd, Number(args[1]));
67
+ }
68
+ if (verb === 'merge') {
69
+ return merge(cwd, Number(args[1]), flags);
70
+ }
71
+ throw new Error(`unknown review verb: ${verb || '(none)'} — use checklist|merge`);
72
+ }
73
+
74
+ module.exports = { checklist, canMerge, ciGreenFor, merge, dispatch };