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.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/commands/verity/architect.md +58 -0
- package/commands/verity/build.md +61 -0
- package/commands/verity/docs.md +37 -0
- package/commands/verity/golive.md +32 -0
- package/commands/verity/map.md +22 -0
- package/commands/verity/plan.md +67 -0
- package/commands/verity/review.md +50 -0
- package/commands/verity/security.md +36 -0
- package/commands/verity/ship.md +67 -0
- package/commands/verity/sre.md +31 -0
- package/commands/verity/test.md +29 -0
- package/commands/verity/verify.md +31 -0
- package/commands/verity/vision.md +55 -0
- package/package.json +31 -0
- package/verity/bin/lib/adr.cjs +67 -0
- package/verity/bin/lib/catalog.cjs +81 -0
- package/verity/bin/lib/config.cjs +119 -0
- package/verity/bin/lib/contract.cjs +57 -0
- package/verity/bin/lib/core.cjs +63 -0
- package/verity/bin/lib/golive.cjs +49 -0
- package/verity/bin/lib/handoff.cjs +72 -0
- package/verity/bin/lib/identity.cjs +112 -0
- package/verity/bin/lib/install.cjs +109 -0
- package/verity/bin/lib/ledger.cjs +244 -0
- package/verity/bin/lib/map.cjs +77 -0
- package/verity/bin/lib/recovery.cjs +37 -0
- package/verity/bin/lib/release.cjs +131 -0
- package/verity/bin/lib/review.cjs +74 -0
- package/verity/bin/lib/scaffold.cjs +66 -0
- package/verity/bin/lib/security.cjs +44 -0
- package/verity/bin/lib/smoke.cjs +170 -0
- package/verity/bin/lib/stage.cjs +180 -0
- package/verity/bin/lib/status.cjs +117 -0
- package/verity/bin/verity.cjs +190 -0
- package/verity/design-guides/contracts-first.md +32 -0
- package/verity/design-guides/features/helper-bot.md +61 -0
- package/verity/design-guides/stack-and-topology.md +38 -0
- package/verity/templates/LICENSE.tmpl +21 -0
- package/verity/templates/README.md.tmpl +14 -0
- package/verity/templates/STATUS.md.tmpl +27 -0
- package/verity/templates/adr.md.tmpl +21 -0
- package/verity/templates/bug_report.yml.tmpl +44 -0
- package/verity/templates/ci.yml.tmpl +36 -0
- package/verity/templates/contract.md.tmpl +21 -0
- package/verity/templates/gitignore.tmpl +9 -0
- package/verity/templates/handoff-brief.md.tmpl +32 -0
- package/verity/templates/handoff-readme.md.tmpl +21 -0
- package/verity/templates/recovery-plan.md.tmpl +29 -0
- package/verity/templates/security-invariants.md.tmpl +14 -0
- package/verity/templates/smoke.json.tmpl +21 -0
- package/verity/templates/stage.md.tmpl +28 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Verity scaffold — generate a target repo's Layer-1 governance + hygiene files
|
|
2
|
+
// from the locked identity manifest (framework-spec.md §6, walking-skeleton plan §6).
|
|
3
|
+
// Stack-AGNOSTIC: the emitted CI is the honest hygiene gate (secret-scan + a
|
|
4
|
+
// structure check) that's genuinely green on a fresh repo. Lint/test gates are
|
|
5
|
+
// added later when the Architect chooses the stack (the progressive gate).
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const identity = require('./identity.cjs');
|
|
10
|
+
const config = require('./config.cjs');
|
|
11
|
+
const { render } = require('./core.cjs');
|
|
12
|
+
|
|
13
|
+
const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
|
|
14
|
+
|
|
15
|
+
const FILES = [
|
|
16
|
+
{ out: 'README.md', tmpl: 'README.md.tmpl' },
|
|
17
|
+
{ out: 'LICENSE', tmpl: 'LICENSE.tmpl' },
|
|
18
|
+
{ out: '.gitignore', tmpl: 'gitignore.tmpl' },
|
|
19
|
+
{ out: '.github/workflows/ci.yml', tmpl: 'ci.yml.tmpl' },
|
|
20
|
+
{ out: '.github/ISSUE_TEMPLATE/bug_report.yml', tmpl: 'bug_report.yml.tmpl' },
|
|
21
|
+
{ out: 'STATUS.md', tmpl: 'STATUS.md.tmpl' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function readTemplate(name) {
|
|
25
|
+
return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function init(cwd, opts = {}) {
|
|
29
|
+
const { manifest } = identity.get(cwd); // throws if identity not locked yet
|
|
30
|
+
const owner = manifest.owner || manifest.name || manifest.slug;
|
|
31
|
+
const vars = {
|
|
32
|
+
name: manifest.name,
|
|
33
|
+
slug: manifest.slug,
|
|
34
|
+
owner,
|
|
35
|
+
image_prefix: manifest.image_prefix || '',
|
|
36
|
+
description: opts.description || '',
|
|
37
|
+
year: String(new Date().getFullYear()),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const created = [];
|
|
41
|
+
const skipped = [];
|
|
42
|
+
for (const file of FILES) {
|
|
43
|
+
const dest = path.join(cwd, file.out);
|
|
44
|
+
if (fs.existsSync(dest) && !opts.force) {
|
|
45
|
+
skipped.push(file.out);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
49
|
+
fs.writeFileSync(dest, render(readTemplate(file.tmpl), vars));
|
|
50
|
+
created.push(file.out);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
config.ensure(cwd);
|
|
54
|
+
return { created, skipped, slug: manifest.slug };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dispatch(args, flags) {
|
|
58
|
+
const verb = args[0];
|
|
59
|
+
const cwd = flags.cwd || process.cwd();
|
|
60
|
+
if (verb === 'init') {
|
|
61
|
+
return init(cwd, { description: flags.description, force: Boolean(flags.force) });
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`unknown scaffold verb: ${verb || '(none)'} — use init`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { render, init, dispatch, FILES };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Security Auditor (framework-spec.md §6, Role 12). DEFINES the invariants; the
|
|
2
|
+
// Reviewer ENFORCES them per-PR. The invariants live in docs/security-invariants.md
|
|
3
|
+
// (committed, canonical) and are read by `review checklist`.
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const TEMPLATE = path.join(__dirname, '..', '..', 'templates', 'security-invariants.md.tmpl');
|
|
8
|
+
|
|
9
|
+
function invariantsPath(cwd) {
|
|
10
|
+
return path.join(cwd, 'docs', 'security-invariants.md');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function init(cwd, opts = {}) {
|
|
14
|
+
const p = invariantsPath(cwd);
|
|
15
|
+
if (fs.existsSync(p) && !opts.force) {
|
|
16
|
+
return { created: false, path: p };
|
|
17
|
+
}
|
|
18
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
19
|
+
fs.writeFileSync(p, fs.readFileSync(TEMPLATE, 'utf8'));
|
|
20
|
+
return { created: true, path: p };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function read(cwd) {
|
|
24
|
+
const p = invariantsPath(cwd);
|
|
25
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dispatch(args, flags) {
|
|
29
|
+
const cwd = flags.cwd || process.cwd();
|
|
30
|
+
const verb = args[0];
|
|
31
|
+
if (verb === 'init') {
|
|
32
|
+
return init(cwd, { force: Boolean(flags.force) });
|
|
33
|
+
}
|
|
34
|
+
if (verb === 'show') {
|
|
35
|
+
const content = read(cwd);
|
|
36
|
+
if (!content) {
|
|
37
|
+
throw new Error('no docs/security-invariants.md — run `verity security init`');
|
|
38
|
+
}
|
|
39
|
+
return { path: invariantsPath(cwd), content };
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`unknown security verb: ${verb || '(none)'} — use init|show`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { invariantsPath, init, read, dispatch };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// UI-smoke "observably-works" gate (framework-spec.md §6, the N2 highest-leverage
|
|
2
|
+
// gate). Drives the REAL UI and asserts BEHAVIOR — the class of failure (dead button,
|
|
3
|
+
// HTMX stub, cache-stale UI) that CI and /health pass while shipping broken.
|
|
4
|
+
//
|
|
5
|
+
// Capability-gated (needs a headless browser): if none is available the gate DEGRADES
|
|
6
|
+
// to a non-pass ("manual Handoff Tester required") — never a false green. The browser
|
|
7
|
+
// driver + the capability probe are injectable so the orchestration is testable.
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { execFileSync } = require('node:child_process');
|
|
11
|
+
|
|
12
|
+
const TEMPLATE = path.join(__dirname, '..', '..', 'templates', 'smoke.json.tmpl');
|
|
13
|
+
|
|
14
|
+
function specPath(cwd) {
|
|
15
|
+
return path.join(cwd, '.verity', 'smoke.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadSpec(cwd) {
|
|
19
|
+
const p = specPath(cwd);
|
|
20
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateSpec(spec) {
|
|
24
|
+
const issues = [];
|
|
25
|
+
if (!spec || !Array.isArray(spec.flows)) {
|
|
26
|
+
issues.push('spec.flows must be an array');
|
|
27
|
+
} else {
|
|
28
|
+
spec.flows.forEach((f, i) => {
|
|
29
|
+
if (!f.name) {
|
|
30
|
+
issues.push(`flow ${i}: missing name`);
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(f.steps)) {
|
|
33
|
+
issues.push(`flow ${i}: steps must be an array`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { valid: issues.length === 0, issues };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Translate a declarative flow into a self-contained Playwright script (pure → testable).
|
|
41
|
+
function buildScript(baseUrl, flow) {
|
|
42
|
+
const lines = [
|
|
43
|
+
"const { chromium } = require('playwright');",
|
|
44
|
+
'(async () => {',
|
|
45
|
+
' const browser = await chromium.launch();',
|
|
46
|
+
' const page = await browser.newPage();',
|
|
47
|
+
' try {',
|
|
48
|
+
];
|
|
49
|
+
for (const step of flow.steps) {
|
|
50
|
+
if ('goto' in step) {
|
|
51
|
+
const url = /^https?:/.test(step.goto) ? step.goto : `${baseUrl || ''}${step.goto}`;
|
|
52
|
+
lines.push(` await page.goto(${JSON.stringify(url)});`);
|
|
53
|
+
} else if ('click' in step) {
|
|
54
|
+
lines.push(` await page.click(${JSON.stringify(step.click)});`);
|
|
55
|
+
} else if ('fill' in step) {
|
|
56
|
+
lines.push(
|
|
57
|
+
` await page.fill(${JSON.stringify(step.fill)}, ${JSON.stringify(step.value || '')});`,
|
|
58
|
+
);
|
|
59
|
+
} else if ('expectSelector' in step) {
|
|
60
|
+
lines.push(
|
|
61
|
+
` await page.waitForSelector(${JSON.stringify(step.expectSelector)}, { timeout: 8000 });`,
|
|
62
|
+
);
|
|
63
|
+
} else if ('expectText' in step) {
|
|
64
|
+
lines.push(
|
|
65
|
+
` { const _c = await page.content(); if (!_c.includes(${JSON.stringify(step.expectText)})) throw new Error('missing text: ' + ${JSON.stringify(step.expectText)}); }`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
lines.push(
|
|
70
|
+
' await browser.close();',
|
|
71
|
+
' } catch (e) {',
|
|
72
|
+
' await browser.close();',
|
|
73
|
+
' console.error(e.message);',
|
|
74
|
+
' process.exit(1);',
|
|
75
|
+
' }',
|
|
76
|
+
'})();',
|
|
77
|
+
);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function defaultProbe(cwd) {
|
|
82
|
+
for (const bin of ['playwright', 'puppeteer']) {
|
|
83
|
+
if (fs.existsSync(path.join(cwd, 'node_modules', '.bin', bin))) {
|
|
84
|
+
return { available: true, tool: bin };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { available: false, tool: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function playwrightDriver(cwd) {
|
|
91
|
+
return (baseUrl, flow) => {
|
|
92
|
+
execFileSync('node', ['-e', buildScript(baseUrl, flow)], {
|
|
93
|
+
cwd,
|
|
94
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runSmoke(cwd, opts = {}) {
|
|
100
|
+
const spec = opts.spec || loadSpec(cwd);
|
|
101
|
+
if (!spec) {
|
|
102
|
+
throw new Error('no smoke spec — run `verity smoke init` then edit .verity/smoke.json');
|
|
103
|
+
}
|
|
104
|
+
const v = validateSpec(spec);
|
|
105
|
+
if (!v.valid) {
|
|
106
|
+
throw new Error(`invalid smoke spec: ${v.issues.join('; ')}`);
|
|
107
|
+
}
|
|
108
|
+
const probe = opts.probe ? opts.probe(cwd) : defaultProbe(cwd);
|
|
109
|
+
if (!probe.available) {
|
|
110
|
+
return {
|
|
111
|
+
gate: 'skipped',
|
|
112
|
+
verified: false,
|
|
113
|
+
reason:
|
|
114
|
+
'no headless browser available — run /verity:verify (Handoff Tester) manually. This is NOT a pass.',
|
|
115
|
+
flows: [],
|
|
116
|
+
raw: 'skipped',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const baseUrl = opts.baseUrl || spec.baseUrl || '';
|
|
120
|
+
const driver = opts.driver || playwrightDriver(cwd);
|
|
121
|
+
const flows = spec.flows.map((f) => {
|
|
122
|
+
try {
|
|
123
|
+
driver(baseUrl, f);
|
|
124
|
+
return { name: f.name, passed: true };
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { name: f.name, passed: false, error: e.message };
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const verified = flows.length > 0 && flows.every((f) => f.passed);
|
|
130
|
+
return {
|
|
131
|
+
gate: verified ? 'passed' : 'failed',
|
|
132
|
+
verified,
|
|
133
|
+
reason: verified ? 'all flows passed' : 'one or more flows failed',
|
|
134
|
+
flows,
|
|
135
|
+
raw: verified ? 'passed' : 'failed',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function init(cwd, opts = {}) {
|
|
140
|
+
const p = specPath(cwd);
|
|
141
|
+
if (fs.existsSync(p) && !opts.force) {
|
|
142
|
+
return { created: false, path: p };
|
|
143
|
+
}
|
|
144
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
145
|
+
fs.writeFileSync(p, fs.readFileSync(TEMPLATE, 'utf8'));
|
|
146
|
+
return { created: true, path: p };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function dispatch(args, flags) {
|
|
150
|
+
const cwd = flags.cwd || process.cwd();
|
|
151
|
+
const verb = args[0] || 'run';
|
|
152
|
+
if (verb === 'init') {
|
|
153
|
+
return init(cwd, { force: Boolean(flags.force) });
|
|
154
|
+
}
|
|
155
|
+
if (verb === 'run') {
|
|
156
|
+
return runSmoke(cwd, { baseUrl: flags['base-url'] });
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`unknown smoke verb: ${verb} — use init|run`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
specPath,
|
|
163
|
+
loadSpec,
|
|
164
|
+
validateSpec,
|
|
165
|
+
buildScript,
|
|
166
|
+
defaultProbe,
|
|
167
|
+
runSmoke,
|
|
168
|
+
init,
|
|
169
|
+
dispatch,
|
|
170
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Stage instructions — stage-instructions/stage-N-slug.md (framework-spec.md §6,
|
|
2
|
+
// Intake/Planner). This is the ONLY place stages are born. Acceptance conditions are
|
|
3
|
+
// pre-filled by work-type so the two biggest interview gaps (kill-switch + UI-smoke)
|
|
4
|
+
// can't be forgotten on a feature.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const { generateSlug, render } = require('./core.cjs');
|
|
10
|
+
|
|
11
|
+
const TEMPLATE = path.join(__dirname, '..', '..', 'templates', 'stage.md.tmpl');
|
|
12
|
+
const TYPES = new Set(['feature', 'bug', 'chore']);
|
|
13
|
+
|
|
14
|
+
function stageDir(cwd) {
|
|
15
|
+
return path.join(cwd, 'stage-instructions');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stageNum(name) {
|
|
19
|
+
const m = name.match(/^stage-(\d+)-/);
|
|
20
|
+
return m ? Number(m[1]) : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nextNumber(cwd) {
|
|
24
|
+
const dir = stageDir(cwd);
|
|
25
|
+
if (!fs.existsSync(dir)) {
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const nums = fs
|
|
29
|
+
.readdirSync(dir)
|
|
30
|
+
.map(stageNum)
|
|
31
|
+
.filter((n) => n > 0);
|
|
32
|
+
return nums.length > 0 ? Math.max(...nums) + 1 : 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function acceptanceFor(type) {
|
|
36
|
+
const suiteGreen = '- [ ] Existing suite stays green; CI all-green';
|
|
37
|
+
if (type === 'bug') {
|
|
38
|
+
return [
|
|
39
|
+
'- [ ] Reproduction captured + a regression test (fails before, passes after)',
|
|
40
|
+
suiteGreen,
|
|
41
|
+
].join('\n');
|
|
42
|
+
}
|
|
43
|
+
if (type === 'chore') {
|
|
44
|
+
return ['- [ ] Clear exit-state defined (what "done" means here)', suiteGreen].join('\n');
|
|
45
|
+
}
|
|
46
|
+
// feature (default) — the two interview gaps baked in:
|
|
47
|
+
return [
|
|
48
|
+
'- [ ] Kill-switch / dark-launch flag (default OFF) for this net-new feature',
|
|
49
|
+
'- [ ] UI-smoke "observably-works" check authored for any user-facing surface',
|
|
50
|
+
'- [ ] Additive migration only (no destructive schema change)',
|
|
51
|
+
suiteGreen,
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function create(cwd, title, opts = {}) {
|
|
56
|
+
if (!title) {
|
|
57
|
+
throw new Error('stage new requires a title');
|
|
58
|
+
}
|
|
59
|
+
const type = opts.type || 'feature';
|
|
60
|
+
if (!TYPES.has(type)) {
|
|
61
|
+
throw new Error(`unknown stage type "${type}" — use feature|bug|chore`);
|
|
62
|
+
}
|
|
63
|
+
const num = nextNumber(cwd);
|
|
64
|
+
const slug = generateSlug(title) || 'stage';
|
|
65
|
+
const rel = path.join('stage-instructions', `stage-${num}-${slug}.md`);
|
|
66
|
+
const file = path.join(cwd, rel);
|
|
67
|
+
fs.mkdirSync(stageDir(cwd), { recursive: true });
|
|
68
|
+
fs.writeFileSync(
|
|
69
|
+
file,
|
|
70
|
+
render(fs.readFileSync(TEMPLATE, 'utf8'), {
|
|
71
|
+
number: String(num),
|
|
72
|
+
title,
|
|
73
|
+
type,
|
|
74
|
+
depends_on: opts.dependsOn || 'none',
|
|
75
|
+
acceptance: acceptanceFor(type),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
// Suggested GitHub work-item the Planner opens for traceability (issue <-> stage <-> PR).
|
|
79
|
+
const issue = {
|
|
80
|
+
title: `[stage ${num}] ${title}`,
|
|
81
|
+
labels: [type, 'needs-triage'],
|
|
82
|
+
body: `Stage ${num} (${type}) — see \`${rel}\`.`,
|
|
83
|
+
};
|
|
84
|
+
return { number: num, slug, type, path: file, rel, issue };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function list(cwd) {
|
|
88
|
+
const dir = stageDir(cwd);
|
|
89
|
+
const stages = fs.existsSync(dir)
|
|
90
|
+
? fs
|
|
91
|
+
.readdirSync(dir)
|
|
92
|
+
.filter((n) => n.endsWith('.md'))
|
|
93
|
+
.sort((a, b) => stageNum(a) - stageNum(b))
|
|
94
|
+
: [];
|
|
95
|
+
return { stages };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Stage Manager acts (branch / PR) ---
|
|
99
|
+
|
|
100
|
+
function findStageFile(cwd, n) {
|
|
101
|
+
const dir = stageDir(cwd);
|
|
102
|
+
if (!fs.existsSync(dir)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return fs.readdirSync(dir).find((name) => stageNum(name) === n) || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function branchName(cwd, n) {
|
|
109
|
+
const file = findStageFile(cwd, n);
|
|
110
|
+
if (!file) {
|
|
111
|
+
throw new Error(`no stage ${n}`);
|
|
112
|
+
}
|
|
113
|
+
const slug = file.replace(/^stage-\d+-/, '').replace(/\.md$/, '');
|
|
114
|
+
return `feat/stage-${n}-${slug}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function acceptanceText(cwd, n) {
|
|
118
|
+
const file = findStageFile(cwd, n);
|
|
119
|
+
if (!file) {
|
|
120
|
+
throw new Error(`no stage ${n}`);
|
|
121
|
+
}
|
|
122
|
+
const text = fs.readFileSync(path.join(stageDir(cwd), file), 'utf8');
|
|
123
|
+
const m = text.match(/##\s+Acceptance conditions\s*\n([\s\S]*?)(?:\n##\s|$)/);
|
|
124
|
+
return (m ? m[1] : '').trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function prSpec(cwd, n, opts = {}) {
|
|
128
|
+
const file = findStageFile(cwd, n);
|
|
129
|
+
if (!file) {
|
|
130
|
+
throw new Error(`no stage ${n}`);
|
|
131
|
+
}
|
|
132
|
+
const text = fs.readFileSync(path.join(stageDir(cwd), file), 'utf8');
|
|
133
|
+
const title = (text.match(/^#\s+Stage\s+\d+:\s+(.+)$/m) || [])[1] || `stage ${n}`;
|
|
134
|
+
const closes = opts.issue ? `\n\nCloses #${opts.issue}` : '';
|
|
135
|
+
const body = `Stage ${n}.\n\n### Acceptance conditions\n${acceptanceText(cwd, n)}${closes}`;
|
|
136
|
+
return { title: `[stage ${n}] ${title.trim()}`, body, branch: branchName(cwd, n) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function run(cmd, args, cwd) {
|
|
140
|
+
execFileSync(cmd, args, { stdio: 'inherit', cwd });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dispatch(args, flags) {
|
|
144
|
+
const cwd = flags.cwd || process.cwd();
|
|
145
|
+
const verb = args[0];
|
|
146
|
+
if (verb === 'new') {
|
|
147
|
+
return create(cwd, args[1], { type: flags.type, dependsOn: flags['depends-on'] });
|
|
148
|
+
}
|
|
149
|
+
if (verb === 'list') {
|
|
150
|
+
return list(cwd);
|
|
151
|
+
}
|
|
152
|
+
if (verb === 'branch') {
|
|
153
|
+
const name = branchName(cwd, Number(args[1]));
|
|
154
|
+
if (!flags['dry-run']) {
|
|
155
|
+
run('git', ['-C', cwd, 'checkout', '-b', name], cwd);
|
|
156
|
+
}
|
|
157
|
+
return { branch: name, created: !flags['dry-run'], raw: name };
|
|
158
|
+
}
|
|
159
|
+
if (verb === 'pr') {
|
|
160
|
+
const spec = prSpec(cwd, Number(args[1]), { issue: flags.issue });
|
|
161
|
+
if (!flags['dry-run']) {
|
|
162
|
+
run('gh', ['pr', 'create', '--title', spec.title, '--body', spec.body], cwd);
|
|
163
|
+
}
|
|
164
|
+
return { ...spec, opened: !flags['dry-run'] };
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`unknown stage verb: ${verb || '(none)'} — use new|list|branch|pr`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
stageDir,
|
|
171
|
+
nextNumber,
|
|
172
|
+
acceptanceFor,
|
|
173
|
+
create,
|
|
174
|
+
list,
|
|
175
|
+
findStageFile,
|
|
176
|
+
branchName,
|
|
177
|
+
acceptanceText,
|
|
178
|
+
prSpec,
|
|
179
|
+
dispatch,
|
|
180
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Release/Deploy Operator — runtime-truth artifact (framework-spec.md §4.6 / D6).
|
|
2
|
+
// `.verity/runtime.json` is the structured single-writer (Operator) store; STATUS.md
|
|
3
|
+
// is the committed human-readable rendering of it. Records secret LOCATIONS only —
|
|
4
|
+
// never values ("a map to secrets, not a copy").
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const { getAt, setAt, coerce } = require('./config.cjs');
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
version: null,
|
|
12
|
+
deployed_at: null,
|
|
13
|
+
rollback_from: null,
|
|
14
|
+
environments: {},
|
|
15
|
+
secret_locations: [],
|
|
16
|
+
notes: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function runtimePath(cwd) {
|
|
20
|
+
return path.join(cwd, '.verity', 'runtime.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function read(cwd) {
|
|
24
|
+
const p = runtimePath(cwd);
|
|
25
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : { ...DEFAULTS };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function write(cwd, data) {
|
|
29
|
+
fs.mkdirSync(path.dirname(runtimePath(cwd)), { recursive: true });
|
|
30
|
+
fs.writeFileSync(runtimePath(cwd), `${JSON.stringify(data, null, 2)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function section(title, items) {
|
|
34
|
+
const out = [`## ${title}`];
|
|
35
|
+
if (items && items.length > 0) {
|
|
36
|
+
for (const x of items) {
|
|
37
|
+
out.push(`- ${x}`);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
out.push('- (none)');
|
|
41
|
+
}
|
|
42
|
+
out.push('');
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function render(cwd, data) {
|
|
47
|
+
const envItems = Object.entries(data.environments || {}).map(
|
|
48
|
+
([k, v]) => `**${k}:** ${typeof v === 'object' ? JSON.stringify(v) : v}`,
|
|
49
|
+
);
|
|
50
|
+
const lines = [
|
|
51
|
+
'# Status & Handoff',
|
|
52
|
+
'',
|
|
53
|
+
'> Runtime/ops truth (framework-spec §4.6). Generated from `.verity/runtime.json`',
|
|
54
|
+
'> by the Release/Deploy Operator. Secret LOCATIONS only — never values.',
|
|
55
|
+
'',
|
|
56
|
+
`**Live version:** ${data.version || '(none)'}`,
|
|
57
|
+
`**Deployed at:** ${data.deployed_at || '(not deployed)'}`,
|
|
58
|
+
`**Rollback from:** ${data.rollback_from || '(n/a)'}`,
|
|
59
|
+
'',
|
|
60
|
+
...section('Environments', envItems),
|
|
61
|
+
...section(
|
|
62
|
+
'Secret locations (names + on-disk locations only, never values)',
|
|
63
|
+
data.secret_locations,
|
|
64
|
+
),
|
|
65
|
+
...section('Coordination notes', data.notes),
|
|
66
|
+
];
|
|
67
|
+
fs.writeFileSync(path.join(cwd, 'STATUS.md'), `${lines.join('\n').trim()}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function show(cwd) {
|
|
71
|
+
return { runtime: read(cwd) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function set(cwd, field, rawValue) {
|
|
75
|
+
if (!field) {
|
|
76
|
+
throw new Error('status set requires a field');
|
|
77
|
+
}
|
|
78
|
+
const data = read(cwd);
|
|
79
|
+
setAt(data, field, coerce(rawValue));
|
|
80
|
+
write(cwd, data);
|
|
81
|
+
render(cwd, data);
|
|
82
|
+
return { field, value: getAt(data, field), runtime: runtimePath(cwd) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function append(cwd, listField, value) {
|
|
86
|
+
const data = read(cwd);
|
|
87
|
+
const list = Array.isArray(data[listField]) ? data[listField] : [];
|
|
88
|
+
list.push(value);
|
|
89
|
+
data[listField] = list;
|
|
90
|
+
write(cwd, data);
|
|
91
|
+
render(cwd, data);
|
|
92
|
+
return { field: listField, count: list.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function dispatch(args, flags) {
|
|
96
|
+
const cwd = flags.cwd || process.cwd();
|
|
97
|
+
const verb = args[0] || 'show';
|
|
98
|
+
if (verb === 'show') {
|
|
99
|
+
return show(cwd);
|
|
100
|
+
}
|
|
101
|
+
if (verb === 'set') {
|
|
102
|
+
return set(cwd, args[1], args[2]);
|
|
103
|
+
}
|
|
104
|
+
if (verb === 'note') {
|
|
105
|
+
return append(cwd, 'notes', args.slice(1).join(' '));
|
|
106
|
+
}
|
|
107
|
+
if (verb === 'secret') {
|
|
108
|
+
return append(cwd, 'secret_locations', args.slice(1).join(' '));
|
|
109
|
+
}
|
|
110
|
+
if (verb === 'render') {
|
|
111
|
+
render(cwd, read(cwd));
|
|
112
|
+
return { rendered: path.join(cwd, 'STATUS.md') };
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`unknown status verb: ${verb} — use show|set|note|secret|render`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { DEFAULTS, runtimePath, read, render, show, set, append, dispatch };
|