yay-layer 1.0.0-rc.1
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/CONSTITUTION.md +55 -0
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/yay.js +3550 -0
- package/package.json +55 -0
- package/src/adopt.js +181 -0
- package/src/adversary.js +119 -0
- package/src/analyze.js +270 -0
- package/src/assurance.js +122 -0
- package/src/attest.js +216 -0
- package/src/capability.js +59 -0
- package/src/constitution.js +162 -0
- package/src/coverage.js +77 -0
- package/src/crypto.js +78 -0
- package/src/dashboard.js +463 -0
- package/src/durable.js +152 -0
- package/src/e2e.js +67 -0
- package/src/extract.js +179 -0
- package/src/foundation.js +143 -0
- package/src/gate.js +252 -0
- package/src/grants.js +249 -0
- package/src/history.js +77 -0
- package/src/ids.js +40 -0
- package/src/manifest.js +303 -0
- package/src/map.js +1742 -0
- package/src/mutate.js +192 -0
- package/src/objects.js +31 -0
- package/src/phone.js +188 -0
- package/src/plan.js +141 -0
- package/src/policy.js +0 -0
- package/src/predicate.js +143 -0
- package/src/prove.js +876 -0
- package/src/ratify.js +28 -0
- package/src/record.js +30 -0
- package/src/reverify.js +212 -0
- package/src/roster.js +117 -0
- package/src/signer-page.js +603 -0
- package/src/specdiff.js +69 -0
- package/src/tags.js +67 -0
- package/src/testrun.js +41 -0
- package/src/util.js +234 -0
- package/src/vendor/recovery.js +217 -0
- package/src/vendor/tweetnacl.min.js +1 -0
- package/src/verify.js +560 -0
- package/standard/STANDARD.md +135 -0
package/bin/yay.js
ADDED
|
@@ -0,0 +1,3550 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// yay — the YayLayer command line.
|
|
4
|
+
// yay init set up YayLayer in this repo
|
|
5
|
+
// yay keygen --name create your signing key (encrypted keystore + public key in the roster)
|
|
6
|
+
// yay adopt [path] scaffold draft specs over existing code
|
|
7
|
+
// yay sign [--all] sign (approve) the current specs [local stand-in for the phone signer]
|
|
8
|
+
// yay verify the gate: paint every Cell green/yellow/red/unsigned
|
|
9
|
+
// yay map [-o file] write the HTML flowchart (defaults to yay-layer-map.html)
|
|
10
|
+
// yay status one-line summary
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const U = require('../src/util');
|
|
16
|
+
const C = require('../src/crypto');
|
|
17
|
+
const R = require('../src/ratify');
|
|
18
|
+
const O = require('../src/objects');
|
|
19
|
+
const A = require('../src/attest');
|
|
20
|
+
const CAP = require('../src/capability');
|
|
21
|
+
const AS = require('../src/assurance');
|
|
22
|
+
const RV = require('../src/reverify');
|
|
23
|
+
const D = require('../src/durable');
|
|
24
|
+
const F = require('../src/foundation');
|
|
25
|
+
const { buildManifest } = require('../src/manifest');
|
|
26
|
+
const { verifyManifest } = require('../src/verify');
|
|
27
|
+
const { renderMap } = require('../src/map');
|
|
28
|
+
const { adopt } = require('../src/adopt');
|
|
29
|
+
const IDS = require('../src/ids');
|
|
30
|
+
const { HARNESSES, writeConstitution, resolveKeys } = require('../src/constitution');
|
|
31
|
+
const { specDiffForCell } = require('../src/specdiff');
|
|
32
|
+
const dashboardMod = require('../src/dashboard');
|
|
33
|
+
const { resolveTestCmd, runTests } = require('../src/testrun');
|
|
34
|
+
const { adversaryManifest, eligible: advEligible } = require('../src/adversary');
|
|
35
|
+
const gate = require('../src/gate');
|
|
36
|
+
const phone = require('../src/phone');
|
|
37
|
+
const rosterMod = require('../src/roster');
|
|
38
|
+
const plan = require('../src/plan');
|
|
39
|
+
const E2E = require('../src/e2e');
|
|
40
|
+
const grantsMod = require('../src/grants');
|
|
41
|
+
const policyMod = require('../src/policy');
|
|
42
|
+
const tagsMod = require('../src/tags');
|
|
43
|
+
|
|
44
|
+
// Load .env / .env.local into process.env (without overriding what's already set).
|
|
45
|
+
// Lets users keep their own ANTHROPIC_API_KEY in a gitignored .env file.
|
|
46
|
+
function loadDotenv() {
|
|
47
|
+
for (const f of ['.env', '.env.local']) {
|
|
48
|
+
let t; try { t = fs.readFileSync(path.join(process.cwd(), f), 'utf8'); } catch (_) { continue; }
|
|
49
|
+
for (const line of t.split(/\r?\n/)) {
|
|
50
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
51
|
+
if (!m || (m[1] in process.env)) continue;
|
|
52
|
+
let v = m[2].trim();
|
|
53
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
54
|
+
process.env[m[1]] = v;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve the LLM provider + key + model for `yay plan` / plan-in-map.
|
|
60
|
+
// Prefers config.plan (set at init), then flags, then whichever key is in env.
|
|
61
|
+
function resolvePlanAuth(config, flags) {
|
|
62
|
+
flags = flags || {};
|
|
63
|
+
const pc = (config && config.plan) || {};
|
|
64
|
+
let provider = (flags.provider && flags.provider !== true) ? String(flags.provider).toLowerCase()
|
|
65
|
+
: (pc.provider || (process.env.ANTHROPIC_API_KEY ? 'anthropic' : ((process.env.OPENAI_API_KEY || process.env.OPENAI_BASE_URL) ? 'openai' : null)));
|
|
66
|
+
if (!provider) return { error: 'set ANTHROPIC_API_KEY or OPENAI_API_KEY in your .env, or pass --provider (anthropic|openai|custom).' };
|
|
67
|
+
const baseUrl = (flags['base-url'] && flags['base-url'] !== true) ? flags['base-url'] : (pc.baseUrl || process.env.OPENAI_BASE_URL || null);
|
|
68
|
+
// custom = any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, a private gateway…). Key optional.
|
|
69
|
+
if (provider === 'custom' && !baseUrl) return { error: 'custom provider needs an endpoint — pass --base-url <url> (or set it during `yay init`).' };
|
|
70
|
+
const apiKey = provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : (process.env.OPENAI_API_KEY || process.env.YAY_PLAN_API_KEY || '');
|
|
71
|
+
if (provider === 'anthropic' && !apiKey) return { error: 'ANTHROPIC_API_KEY is not set in your .env.' };
|
|
72
|
+
if (provider === 'openai' && !apiKey) return { error: 'OPENAI_API_KEY is not set in your .env.' };
|
|
73
|
+
const model = (flags.model && flags.model !== true) ? flags.model : (pc.model || (provider === 'anthropic' ? 'claude-sonnet-5' : (provider === 'openai' ? 'gpt-4o' : null)));
|
|
74
|
+
if (provider === 'custom' && !model) return { error: 'custom provider needs a model — pass --model <id> (or set it during `yay init`).' };
|
|
75
|
+
return { provider, apiKey, model, baseUrl };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rosterPath(p) { return path.join(path.dirname(p.config), 'roster.json'); }
|
|
79
|
+
function loadRoster(p) { return U.readJSON(rosterPath(p), null); }
|
|
80
|
+
// Who THIS machine signs as (recorded at pair/keygen). Machine-local + gitignored — it
|
|
81
|
+
// differs per person, so it must never be committed. Lets `yay sign` default to the
|
|
82
|
+
// local signer instead of the project owner (config.owners[0]).
|
|
83
|
+
function localPath(p) { return path.join(path.dirname(p.config), 'local.json'); }
|
|
84
|
+
function loadLocalSigner(p) { const d = U.readJSON(localPath(p), null); return d && d.signer ? d.signer : null; }
|
|
85
|
+
function saveLocalSigner(p, name) { try { if (name) { const d = U.readJSON(localPath(p), null) || {}; d.signer = name; U.writeJSON(localPath(p), d); ensureGitignored(p.root, '.yaylayer/local.json'); } } catch (_) {} }
|
|
86
|
+
// This working copy's Cell-id SHARD. Lives in gitignored local.json (never committed) so every clone
|
|
87
|
+
// gets its OWN shard → new ids from different clones never collide on merge. First 2 hex encode the
|
|
88
|
+
// local signer's identity (when known), last 2 are per-copy random. Created lazily, then stable.
|
|
89
|
+
function ensureLocalShard(p) {
|
|
90
|
+
const d = U.readJSON(localPath(p), null) || {};
|
|
91
|
+
if (d.idShard) return d.idShard;
|
|
92
|
+
let idPart = null;
|
|
93
|
+
try { const cfg = U.readJSON(p.config, null) || {}; const first = Object.values(cfg.signers || {})[0]; const pub = first && (first.pub || first); if (pub) idPart = IDS.deriveShard(pub).slice(0, 2); } catch (_) {}
|
|
94
|
+
d.idShard = (idPart || IDS.deriveShard(null).slice(0, 2)) + IDS.deriveShard(null).slice(0, 2);
|
|
95
|
+
try { U.writeJSON(localPath(p), d); ensureGitignored(p.root, '.yaylayer/local.json'); } catch (_) {}
|
|
96
|
+
return d.idShard;
|
|
97
|
+
}
|
|
98
|
+
// Every Cell id the ledger has ever recorded (signed) — so a deleted-but-once-signed id is retired forever.
|
|
99
|
+
function ledgerCellIds(p) {
|
|
100
|
+
const lock = U.readJSON(p.lock, null); const out = [];
|
|
101
|
+
if (lock && Array.isArray(lock.approvals)) for (const ap of lock.approvals) { if (ap && ap.items) for (const k of Object.keys(ap.items)) out.push(k); }
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
// Autopilot: the append-only, owner-signed grants log (committed) + the machine-held
|
|
105
|
+
// grant private keys (in the gitignored keys/, used for UNATTENDED auto-signing).
|
|
106
|
+
function grantsPath(p) { return path.join(path.dirname(p.config), 'grants.json'); }
|
|
107
|
+
function rejectionsPath(p) { return path.join(path.dirname(p.config), 'rejections.json'); }
|
|
108
|
+
function loadGrants(p) { return U.readJSON(grantsPath(p), null); }
|
|
109
|
+
function grantKeyPath(p, id) { return path.join(p.keys, `grant-${id}.json`); }
|
|
110
|
+
function parseDuration(s) {
|
|
111
|
+
const m = String(s).trim().match(/^(\d+)\s*([smhd])$/i);
|
|
112
|
+
if (!m) return null;
|
|
113
|
+
const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2].toLowerCase()];
|
|
114
|
+
return Number(m[1]) * mult;
|
|
115
|
+
}
|
|
116
|
+
// Stamp a Cell's SOURCE (a `//∷YAY-DELEGATED⟨id⟩` comment ABOVE its opening marker, so it never
|
|
117
|
+
// touches the spec block / specHash) to mark it delegated (Autopilot), and remove it on ratify.
|
|
118
|
+
// So the file itself says "delegated, awaiting ratification" — visible in the code, not just the seal.
|
|
119
|
+
function stampAutoCell(p, cell, grant) {
|
|
120
|
+
try {
|
|
121
|
+
const abs = path.join(p.root, cell.file);
|
|
122
|
+
if (!fs.existsSync(abs)) return;
|
|
123
|
+
const lines = fs.readFileSync(abs, 'utf8').split('\n');
|
|
124
|
+
const idRe = cell.id.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
125
|
+
const openRe = new RegExp('∷YAY⟨\\s*' + idRe + '\\s*⟩');
|
|
126
|
+
const autoRe = new RegExp('∷YAY-DELEGATED⟨\\s*' + idRe + '\\s*⟩');
|
|
127
|
+
const i = lines.findIndex((l) => openRe.test(l));
|
|
128
|
+
if (i < 0) return;
|
|
129
|
+
const indent = (lines[i].match(/^\s*/) || [''])[0];
|
|
130
|
+
const stamp = indent + '//∷YAY-DELEGATED⟨' + cell.id + '⟩ delegated · grant ' + grant + ' · awaiting ratification — run `yay ratify`';
|
|
131
|
+
if (i > 0 && autoRe.test(lines[i - 1])) lines[i - 1] = stamp; else lines.splice(i, 0, stamp);
|
|
132
|
+
fs.writeFileSync(abs, lines.join('\n'));
|
|
133
|
+
} catch (_) { /* best effort — the seal in lock.json is the source of truth */ }
|
|
134
|
+
}
|
|
135
|
+
function unstampAutoCell(p, cell) {
|
|
136
|
+
try {
|
|
137
|
+
const abs = path.join(p.root, cell.file);
|
|
138
|
+
if (!fs.existsSync(abs)) return;
|
|
139
|
+
const lines = fs.readFileSync(abs, 'utf8').split('\n');
|
|
140
|
+
const idRe = cell.id.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
141
|
+
const autoRe = new RegExp('∷YAY-DELEGATED⟨\\s*' + idRe + '\\s*⟩');
|
|
142
|
+
const i = lines.findIndex((l) => autoRe.test(l));
|
|
143
|
+
if (i >= 0) { lines.splice(i, 1); fs.writeFileSync(abs, lines.join('\n')); }
|
|
144
|
+
} catch (_) { /* best effort */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Infer how this project signs, for method-aware Constitution guidance.
|
|
148
|
+
function signMethodOf(config) {
|
|
149
|
+
const kinds = new Set();
|
|
150
|
+
for (const n of Object.keys((config && config.signers) || {})) {
|
|
151
|
+
const list = Array.isArray(config.signers[n]) ? config.signers[n] : [config.signers[n]];
|
|
152
|
+
for (const k of list) if (k && typeof k === 'object' && k.kind) kinds.add(k.kind);
|
|
153
|
+
}
|
|
154
|
+
if (config && config.devices && Object.keys(config.devices).length) kinds.add('phone');
|
|
155
|
+
const hasPhone = kinds.has('phone'), hasLocal = kinds.has('local');
|
|
156
|
+
if (hasPhone && !hasLocal) return 'phone';
|
|
157
|
+
if (hasLocal && !hasPhone) return 'local';
|
|
158
|
+
return null; // unknown or mixed → generic guidance
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Decide how `yay sign` signs when no explicit flag is given: use the project's
|
|
162
|
+
// established method so you never have to type --phone. Explicit --phone/--local win.
|
|
163
|
+
function resolveSignMethod(config, p, name, flags) {
|
|
164
|
+
if (flags.phone) return 'phone';
|
|
165
|
+
if (flags.local) return 'local';
|
|
166
|
+
const m = signMethodOf(config);
|
|
167
|
+
if (m) return m; // project clearly signs one way
|
|
168
|
+
// mixed/unknown: a local keystore on this machine → local, else the phone
|
|
169
|
+
return fs.existsSync(path.join(p.keys, `${name}.keystore`)) ? 'local' : 'phone';
|
|
170
|
+
}
|
|
171
|
+
function trustRootPin(flags) { return (flags.root && flags.root !== true) ? flags.root : (process.env.YAY_TRUST_ROOT || null); }
|
|
172
|
+
|
|
173
|
+
// Print a scannable QR of a URL to the terminal (graceful if the lib is absent).
|
|
174
|
+
function printQR(url) {
|
|
175
|
+
try { require('qrcode-terminal').generate(url, { small: true }, (q) => console.log(q)); }
|
|
176
|
+
catch (_) { console.log(U.c.dim(' (install qrcode-terminal for a scannable QR)')); }
|
|
177
|
+
}
|
|
178
|
+
// Self-signed TLS for phone signing, ON BY DEFAULT (--no-https opts out). Cert cached in the gitignored
|
|
179
|
+
// keys dir; generated with openssl. Returns { key, cert } or null (→ falls back to http).
|
|
180
|
+
function tlsCert(p, flags) {
|
|
181
|
+
// HTTPS is the default (SSL over the LAN); --no-https opts out for plain http.
|
|
182
|
+
if (flags['no-https']) return null;
|
|
183
|
+
const cp = require('child_process');
|
|
184
|
+
const ip = phone.lanIP();
|
|
185
|
+
fs.mkdirSync(p.keys, { recursive: true });
|
|
186
|
+
// The cert's SubjectAltName is bound to the current LAN IP; if we switched networks
|
|
187
|
+
// the cached cert would fail hostname validation, so track the IP and regenerate on change.
|
|
188
|
+
const freshFor = (crtP, ipP) => fs.existsSync(crtP) && fs.existsSync(ipP) && fs.readFileSync(ipP, 'utf8').trim() === ip;
|
|
189
|
+
|
|
190
|
+
// Prefer mkcert: a LOCALLY-TRUSTED cert → no browser warning (real server auth + encryption).
|
|
191
|
+
try {
|
|
192
|
+
const caRoot = cp.execFileSync('mkcert', ['-CAROOT'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
193
|
+
if (caRoot && fs.existsSync(path.join(caRoot, 'rootCA.pem'))) {
|
|
194
|
+
const keyP = path.join(p.keys, 'tls-mkcert.key'), crtP = path.join(p.keys, 'tls-mkcert.crt'), ipP = path.join(p.keys, 'tls-mkcert.ip');
|
|
195
|
+
if (!fs.existsSync(keyP) || !freshFor(crtP, ipP)) {
|
|
196
|
+
cp.execFileSync('mkcert', ['-key-file', keyP, '-cert-file', crtP, ip, 'localhost', '127.0.0.1', '::1'], { stdio: 'ignore' });
|
|
197
|
+
fs.writeFileSync(ipP, ip);
|
|
198
|
+
}
|
|
199
|
+
return { key: fs.readFileSync(keyP, 'utf8'), cert: fs.readFileSync(crtP, 'utf8'), trusted: true, caRoot, ip };
|
|
200
|
+
}
|
|
201
|
+
} catch (_) { /* mkcert absent → fall through to self-signed */ }
|
|
202
|
+
|
|
203
|
+
// Fallback: self-signed via openssl — still encrypted, but a one-time "not private" warning.
|
|
204
|
+
const keyP = path.join(p.keys, 'tls.key'), crtP = path.join(p.keys, 'tls.crt'), ipP = path.join(p.keys, 'tls.ip');
|
|
205
|
+
try {
|
|
206
|
+
if (!fs.existsSync(keyP) || !freshFor(crtP, ipP)) {
|
|
207
|
+
cp.execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyP, '-out', crtP, '-days', '825', '-subj', '/CN=yaylayer', '-addext', 'subjectAltName=IP:' + ip + ',DNS:localhost'], { stdio: 'ignore' });
|
|
208
|
+
fs.writeFileSync(ipP, ip);
|
|
209
|
+
}
|
|
210
|
+
return { key: fs.readFileSync(keyP, 'utf8'), cert: fs.readFileSync(crtP, 'utf8'), trusted: false, ip };
|
|
211
|
+
} catch (_) { console.log(U.c.yellow(' ⚠ could not create an HTTPS cert (is openssl installed?) — falling back to http. Pass --no-https to silence.')); return null; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function args(argv) {
|
|
215
|
+
const flags = {}; const positional = [];
|
|
216
|
+
for (let i = 0; i < argv.length; i++) {
|
|
217
|
+
const a = argv[i];
|
|
218
|
+
let key = null;
|
|
219
|
+
if (a.startsWith('--')) key = a.slice(2);
|
|
220
|
+
else if (/^-[A-Za-z]$/.test(a)) key = a.slice(1); // short flags like -o
|
|
221
|
+
if (key !== null) {
|
|
222
|
+
const next = argv[i + 1];
|
|
223
|
+
if (next !== undefined && !next.startsWith('-')) flags[key] = argv[++i];
|
|
224
|
+
else flags[key] = true;
|
|
225
|
+
} else positional.push(a);
|
|
226
|
+
}
|
|
227
|
+
return { flags, positional };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Read a hidden passphrase from a terminal (typing is not echoed).
|
|
231
|
+
function promptHidden(q) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const stdin = process.stdin;
|
|
234
|
+
process.stdout.write(q);
|
|
235
|
+
stdin.resume();
|
|
236
|
+
stdin.setEncoding('utf8');
|
|
237
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
238
|
+
let input = '';
|
|
239
|
+
const finish = (val) => {
|
|
240
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
241
|
+
stdin.removeListener('data', onData);
|
|
242
|
+
stdin.pause();
|
|
243
|
+
process.stdout.write('\n');
|
|
244
|
+
resolve(val);
|
|
245
|
+
};
|
|
246
|
+
const onData = (chunk) => {
|
|
247
|
+
for (const ch of String(chunk)) {
|
|
248
|
+
const code = ch.charCodeAt(0);
|
|
249
|
+
if (ch === '\n' || ch === '\r' || code === 4) return finish(input); // Enter / Ctrl-D
|
|
250
|
+
if (code === 3) { process.stdout.write('\n'); process.exit(1); } // Ctrl-C
|
|
251
|
+
if (code === 127 || code === 8) { input = input.slice(0, -1); continue; } // backspace
|
|
252
|
+
input += ch;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
stdin.on('data', onData);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Read one plain line (for piped input, and visible interactive prompts).
|
|
260
|
+
function promptLine() {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
const stdin = process.stdin;
|
|
263
|
+
let data = '';
|
|
264
|
+
stdin.setEncoding('utf8');
|
|
265
|
+
stdin.resume();
|
|
266
|
+
const onData = (c) => {
|
|
267
|
+
data += c;
|
|
268
|
+
const nl = data.indexOf('\n');
|
|
269
|
+
if (nl >= 0) { stdin.removeListener('data', onData); stdin.pause(); resolve(data.slice(0, nl).replace(/\r$/, '')); }
|
|
270
|
+
};
|
|
271
|
+
stdin.on('data', onData);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ask a question on a TTY and read a visible line back.
|
|
276
|
+
async function ask(prompt) {
|
|
277
|
+
process.stdout.write(prompt);
|
|
278
|
+
return (await promptLine()).trim();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function getPassphrase(flags, purpose) {
|
|
282
|
+
if (flags.passphrase && flags.passphrase !== true) return flags.passphrase;
|
|
283
|
+
if (process.env.YAY_PASSPHRASE) return process.env.YAY_PASSPHRASE;
|
|
284
|
+
if (process.stdin.isTTY) {
|
|
285
|
+
console.log(U.c.accent('▸ ') + (purpose || 'Enter your passphrase') +
|
|
286
|
+
U.c.dim(' (typing is hidden — type it, then press Enter)'));
|
|
287
|
+
return promptHidden(' passphrase: ');
|
|
288
|
+
}
|
|
289
|
+
return promptLine(); // piped input
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function loadState() {
|
|
293
|
+
const root = U.repoRoot();
|
|
294
|
+
const p = U.paths(root);
|
|
295
|
+
const config = U.readJSON(p.config, null);
|
|
296
|
+
const lock = U.readJSON(p.lock, { approvals: [] });
|
|
297
|
+
return { root, p, config, lock };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Create a local signing key: encrypted keystore on disk + public key into the roster.
|
|
301
|
+
// Add a public key to an identity (one identity may hold several keys: local +
|
|
302
|
+
// phone …). Idempotent; migrates a legacy single-string entry to a list.
|
|
303
|
+
function addSignerKey(config, name, pub, kind) {
|
|
304
|
+
config.signers = config.signers || {};
|
|
305
|
+
const cur = config.signers[name];
|
|
306
|
+
let list;
|
|
307
|
+
if (Array.isArray(cur)) list = cur.slice();
|
|
308
|
+
else if (typeof cur === 'string') list = [{ pub: cur, kind: 'local' }];
|
|
309
|
+
else if (cur && cur.pub) list = [cur];
|
|
310
|
+
else list = [];
|
|
311
|
+
const has = list.some((k) => (typeof k === 'string' ? k : k.pub) === pub);
|
|
312
|
+
if (!has) list.push({ pub, kind: kind || 'key', addedAt: new Date().toISOString() });
|
|
313
|
+
config.signers[name] = list;
|
|
314
|
+
config.owners = config.owners || [];
|
|
315
|
+
if (!config.owners.includes(name)) config.owners.push(name);
|
|
316
|
+
return has ? 'exists' : 'added';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createKey(p, config, name, pass, genesisMeta) {
|
|
320
|
+
const { pubB64, privDer } = C.generateKeypair();
|
|
321
|
+
const ks = C.encryptKeystore(privDer, pass);
|
|
322
|
+
fs.mkdirSync(p.keys, { recursive: true });
|
|
323
|
+
const ksPath = path.join(p.keys, `${name}.keystore`);
|
|
324
|
+
fs.writeFileSync(ksPath, JSON.stringify(ks, null, 2) + '\n', { mode: 0o600 });
|
|
325
|
+
addSignerKey(config, name, pubB64, 'local');
|
|
326
|
+
U.writeJSON(p.config, config);
|
|
327
|
+
// Establish the signed trust root on the very first key (genesis, self-signed). When this
|
|
328
|
+
// genesis is the result of a reroot, genesisMeta stamps the rotation INTO the signed event
|
|
329
|
+
// (supersedes/rerootedAt/rerootReason) so the audit shows a reroot happened, and why.
|
|
330
|
+
const rp = rosterPath(p);
|
|
331
|
+
if (!fs.existsSync(rp)) {
|
|
332
|
+
const ev = { id: 'R-0001', type: 'genesis', name, pub: pubB64, role: 'owner', by: name, prev: 'genesis', nonce: C.randomNonce(), at: new Date().toISOString(), ...(genesisMeta || {}) };
|
|
333
|
+
ev.signature = C.sign(rosterMod.eventBytes(ev), privDer);
|
|
334
|
+
U.writeJSON(rp, { project: config.project, events: [ev] });
|
|
335
|
+
console.log(' ' + U.c.dim('trust root established → ' + rosterMod.fingerprint(pubB64)) + U.c.dim(' (pin this in CI)'));
|
|
336
|
+
}
|
|
337
|
+
return ksPath;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── commands ──────────────────────────────────────────────
|
|
341
|
+
async function cmdInit(flags, positional) {
|
|
342
|
+
// Guided setup: create files → choose a signing key (local/mobile) → optional adopt.
|
|
343
|
+
// Interactive on a TTY; fully scriptable via flags (--key, --name, --passphrase,
|
|
344
|
+
// --adopt/--no-adopt) and safe (never hangs) when non-interactive.
|
|
345
|
+
const target = path.resolve(positional[0] || process.cwd());
|
|
346
|
+
if (!fs.existsSync(target)) return fail(`no such directory: ${target}`);
|
|
347
|
+
if (typeof flags.project === 'string' && flags.project.includes('/')) {
|
|
348
|
+
console.log(U.c.yellow('note: ') + '`--project` is a display NAME, not a path. To set up another folder, pass it as a directory:');
|
|
349
|
+
console.log(' ' + U.c.bold(`yay init ${flags.project}`) + '\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const p = U.paths(target);
|
|
353
|
+
const rel = path.relative(process.cwd(), target) || '.';
|
|
354
|
+
const tty = !!process.stdin.isTTY;
|
|
355
|
+
|
|
356
|
+
// 1 ── files
|
|
357
|
+
let config = U.readJSON(p.config, null);
|
|
358
|
+
if (config) {
|
|
359
|
+
console.log(U.c.dim('• .yaylayer already exists here — continuing setup.'));
|
|
360
|
+
} else {
|
|
361
|
+
const nameFlag = (flags.project && flags.project !== true && !String(flags.project).includes('/')) ? flags.project : null;
|
|
362
|
+
const project = nameFlag || path.basename(target);
|
|
363
|
+
config = { project, created: new Date().toISOString(), signers: {}, owners: [] };
|
|
364
|
+
// Provenance mode (D10): Standard (default; specs/attestations kept, bulk source via git) vs
|
|
365
|
+
// Durable (also keeps an encrypted, sha256-anchored archive of signed source). Switchable later
|
|
366
|
+
// with `yay archive enable|disable`.
|
|
367
|
+
if (flags.durable) config.provenance = { mode: 'durable' };
|
|
368
|
+
U.writeJSON(p.config, config);
|
|
369
|
+
U.writeJSON(p.lock, { project, approvals: [] });
|
|
370
|
+
fs.mkdirSync(p.keys, { recursive: true });
|
|
371
|
+
ensureLedgerMergeAttrs(target); // append-only ledgers auto-merge instead of conflicting
|
|
372
|
+
console.log(U.c.green('✓ initialized YayLayer') + ` for "${project}"` + (rel === '.' ? '' : U.c.dim(` in ${rel}/`)));
|
|
373
|
+
console.log(' ' + U.c.dim(`config + lock → ${path.join(rel, '.yaylayer')}/ (commit these) · keys → gitignored`));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 2 ── signing key
|
|
377
|
+
let keyChoice = (typeof flags.key === 'string') ? flags.key.toLowerCase() : null;
|
|
378
|
+
if (!keyChoice) {
|
|
379
|
+
if (tty && !Object.keys(config.signers).length) {
|
|
380
|
+
console.log('\n' + U.c.bold('Signing key') + ' — you need one to approve (sign) specs.');
|
|
381
|
+
console.log(' ' + U.c.bold('1') + ') Local ' + U.c.yellow('(least secure)') + U.c.dim(' — key stored on this machine, passphrase-encrypted; signs on the same box as the AI'));
|
|
382
|
+
console.log(' ' + U.c.bold('2') + ') Mobile, LAN ' + U.c.dim('— key lives only on your phone; phone ⇄ laptop directly over your Wi-Fi (private, no server); what you see is what you sign'));
|
|
383
|
+
console.log(' ' + U.c.bold('3') + ') Mobile, relay ' + U.c.green('(recommended)') + U.c.dim(' — same as LAN but via relay.yaylayer.com so it works off your LAN (end-to-end encrypted; the relay never sees your code); what you see is what you sign'));
|
|
384
|
+
const ans = await ask(' Choose 1, 2 or 3 (Enter to skip): ');
|
|
385
|
+
keyChoice = ans === '1' ? 'local' : (ans === '2' || ans === '3') ? 'mobile' : 'none';
|
|
386
|
+
if (ans === '3') config.transport = 'relay';
|
|
387
|
+
} else keyChoice = 'none';
|
|
388
|
+
}
|
|
389
|
+
// Flag overrides (scriptable): --relay picks the hosted-relay transport; --lan the LAN one.
|
|
390
|
+
if (flags.relay) { keyChoice = 'mobile'; config.transport = 'relay'; }
|
|
391
|
+
if (flags.lan && config.transport === 'relay') delete config.transport;
|
|
392
|
+
if (keyChoice === 'mobile') U.writeJSON(p.config, config); // persist the transport choice
|
|
393
|
+
|
|
394
|
+
if (keyChoice === 'mobile') {
|
|
395
|
+
const via = config.transport === 'relay' ? ' (via relay.yaylayer.com)' : ' (over your Wi-Fi)';
|
|
396
|
+
console.log('\n' + U.c.bold('Mobile signing') + via + U.c.dim(' — your key is created and stays on your phone; this machine never holds it.'));
|
|
397
|
+
const nameFlag = (flags.name && flags.name !== true) ? flags.name : null;
|
|
398
|
+
const pairNow = flags.pair ? true : (flags['no-pair'] ? false : (tty ? /^y/i.test((await ask(' Pair your phone now? (Y/n): ')) || 'y') : false));
|
|
399
|
+
if (pairNow) await runPairing(p, config, flags);
|
|
400
|
+
else console.log(' ' + U.c.dim('skipped — pair anytime with ') + U.c.bold('yay pair') + U.c.dim('.'));
|
|
401
|
+
} else if (keyChoice === 'local') {
|
|
402
|
+
let name = (flags.name && flags.name !== true) ? flags.name : null;
|
|
403
|
+
if (!name && tty) name = await ask(' Your signer name (e.g. alice, or "Alice Carlsen"): ');
|
|
404
|
+
if (!name) name = 'you';
|
|
405
|
+
if (fs.existsSync(path.join(p.keys, `${name}.keystore`))) {
|
|
406
|
+
console.log(U.c.dim(`• local key for "${name}" already exists — skipping.`));
|
|
407
|
+
} else {
|
|
408
|
+
const pass = await getPassphrase(flags, `Set a passphrase to encrypt ${name}'s key (you'll re-enter it each time you sign)`);
|
|
409
|
+
if (!pass || pass.length < 6) fail('passphrase must be at least 6 characters — key not created. Run `yay keygen` later.');
|
|
410
|
+
else {
|
|
411
|
+
const ksPath = createKey(p, config, name, pass);
|
|
412
|
+
console.log(U.c.green(`✓ local key created for "${name}"`) + U.c.dim(` → ${path.relative(process.cwd(), ksPath)} (encrypted, gitignored)`));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// This working copy's collision-free Cell-id shard (now that any local key exists to flavour it).
|
|
418
|
+
const myShard = ensureLocalShard(p);
|
|
419
|
+
|
|
420
|
+
// 3 ── adopt existing code
|
|
421
|
+
let doAdopt = flags.adopt ? true : (flags['no-adopt'] ? false : null);
|
|
422
|
+
if (doAdopt === null) {
|
|
423
|
+
doAdopt = tty ? /^y/i.test(await ask('\nDoes this project already have code to bring under YayLayer? Run `adopt` now? (y/N): ')) : false;
|
|
424
|
+
}
|
|
425
|
+
if (doAdopt) {
|
|
426
|
+
const res = adopt(target, { dry: false, shard: ensureLocalShard(p), ledgerIds: ledgerCellIds(p) });
|
|
427
|
+
if (!res.total) console.log(U.c.dim('• adopt: no un-tagged top-level functions found.'));
|
|
428
|
+
else {
|
|
429
|
+
console.log(U.c.green(`✓ adopt: scaffolded ${res.total} draft Cell(s) across ${res.report.length} file(s)`));
|
|
430
|
+
console.log(U.c.dim(' each is DERIVED + unsigned — prune them, then `yay sign`.'));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 3.5 ── Brief tags: a project vocabulary so what you build can be sorted by concern.
|
|
435
|
+
let tagChoice = (typeof flags.tags === 'string') ? flags.tags.toLowerCase() : null;
|
|
436
|
+
if (!tagChoice && !tagsMod.loadTags(p)) {
|
|
437
|
+
if (tty) {
|
|
438
|
+
console.log('\n' + U.c.bold('Brief tags') + U.c.dim(' — a small vocabulary every Brief is tagged with, so you can later sort what you built by concern + time. Pick a starter set:'));
|
|
439
|
+
tagsMod.TAG_SETS.forEach((s, i) => console.log(' ' + U.c.bold(String(i + 1)) + ') ' + s.name.padEnd(22) + U.c.dim(s.desc)));
|
|
440
|
+
const customN = tagsMod.TAG_SETS.length + 1;
|
|
441
|
+
console.log(' ' + U.c.bold(String(customN)) + ') ' + 'Custom'.padEnd(22) + U.c.dim('blank placeholders (Custom 1–4) you relabel yourself later'));
|
|
442
|
+
console.log(' ' + U.c.dim('(switch sets, relabel, or add your own anytime with `yay tags` or the dashboard)'));
|
|
443
|
+
const ans = (await ask(` Choose 1–${customN}, or Enter to skip tagging: `)).trim();
|
|
444
|
+
const idx = parseInt(ans, 10);
|
|
445
|
+
tagChoice = (idx >= 1 && idx <= tagsMod.TAG_SETS.length) ? tagsMod.TAG_SETS[idx - 1].id : (idx === customN ? 'custom' : 'none');
|
|
446
|
+
} else tagChoice = 'none';
|
|
447
|
+
}
|
|
448
|
+
if (tagChoice && tagChoice !== 'none' && !tagsMod.loadTags(p)) {
|
|
449
|
+
if (tagChoice === 'custom') {
|
|
450
|
+
tagsMod.saveTags(p, { project: config.project, set: 'custom', tags: tagsMod.CUSTOM_SEED.slice(), descriptions: {} });
|
|
451
|
+
console.log(U.c.green('✓ tags: custom placeholders') + U.c.dim(' (Custom 1–4) → .yaylayer/tags.json. Relabel them in the dashboard Tags tab, `yay tags rename`, or the file.'));
|
|
452
|
+
} else {
|
|
453
|
+
const set = tagsMod.setById(tagChoice);
|
|
454
|
+
if (!set) console.log(U.c.yellow(` unknown tag set "${tagChoice}" — skipping (options: ${tagsMod.TAG_SETS.map((s) => s.id).join(', ')}, custom).`));
|
|
455
|
+
else {
|
|
456
|
+
tagsMod.saveTags(p, { project: config.project, set: set.id, tags: set.tags.slice() });
|
|
457
|
+
console.log(U.c.green(`✓ tags: "${set.name}"`) + U.c.dim(` (${set.tags.length} tags) → .yaylayer/tags.json (commit this). Edit with `) + U.c.bold('yay tags') + U.c.dim('.'));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 4 ── instruct the AI harness(es) to follow YayLayer from the start
|
|
463
|
+
let conSpec = (typeof flags.constitution === 'string') ? flags.constitution : (flags.constitution === true ? 'all' : null);
|
|
464
|
+
if (conSpec === null && tty) {
|
|
465
|
+
console.log('\n' + U.c.bold('Instruct your AI to follow YayLayer') + ' — write the Constitution where the tool auto-reads it.');
|
|
466
|
+
for (const h of HARNESSES) console.log(' ' + U.c.accent(h.key.padEnd(9)) + U.c.dim(h.path.padEnd(34) + h.note));
|
|
467
|
+
const ans = await ask(' Which? comma-separated keys, "all", or Enter to skip: ');
|
|
468
|
+
conSpec = ans.trim() || 'none';
|
|
469
|
+
}
|
|
470
|
+
if (conSpec && conSpec !== 'none') {
|
|
471
|
+
const cMethod = keyChoice === 'mobile' ? 'phone' : keyChoice === 'local' ? 'local' : signMethodOf(config);
|
|
472
|
+
for (const r of writeConstitution(target, resolveKeys(conSpec), cMethod)) {
|
|
473
|
+
if (r.error) console.log(U.c.red(' ✗ ') + r.key + ' — ' + r.error);
|
|
474
|
+
else console.log(' ' + U.c.green('✓ ') + r.action.padEnd(9) + ' ' + r.path + U.c.dim(` (${r.label})`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 5 ── optional: configure the project's AI (needs an API key). One provider powers ALL the AI
|
|
479
|
+
// features — the System Plan overview, the Ask assistant, and the spec-only adversary.
|
|
480
|
+
let planAns = flags.plan ? 'y' : (flags['no-plan'] ? 'n' : null);
|
|
481
|
+
if (planAns === null && tty) {
|
|
482
|
+
console.log('\n' + U.c.bold('Your project AI') + U.c.dim(' — one provider that powers every AI feature: the System Plan overview, the'));
|
|
483
|
+
console.log(' ' + U.c.dim('Ask assistant (query your repo + the manual), and the spec-only adversary.'));
|
|
484
|
+
console.log(' ' + U.c.dim('It calls an LLM, so it needs your own API key (stored in .env, gitignored). You can add or change it later.'));
|
|
485
|
+
planAns = /^y/i.test((await ask(' Configure your project AI now (System Plan, Ask, adversary)? (y/N): ')) || 'n') ? 'y' : 'n';
|
|
486
|
+
}
|
|
487
|
+
if (planAns === 'y') {
|
|
488
|
+
let provider = (flags.provider && flags.provider !== true) ? String(flags.provider).toLowerCase() : null;
|
|
489
|
+
if (!provider && tty) {
|
|
490
|
+
const a = (await ask(' Provider — [a]nthropic, [o]penai, or [c]ustom (local / OpenAI-compatible)? (a/o/c): ')) || 'a';
|
|
491
|
+
provider = /^c/i.test(a) ? 'custom' : (/^o/i.test(a) ? 'openai' : 'anthropic');
|
|
492
|
+
}
|
|
493
|
+
provider = provider || 'anthropic';
|
|
494
|
+
const plan = { enabled: true, provider };
|
|
495
|
+
if (provider === 'custom') {
|
|
496
|
+
let baseUrl = (flags['base-url'] && flags['base-url'] !== true) ? flags['base-url'] : '';
|
|
497
|
+
if (!baseUrl && tty) baseUrl = (await ask(' Endpoint URL (e.g. http://localhost:11434/v1 for Ollama): ')).trim();
|
|
498
|
+
let model = (flags.model && flags.model !== true) ? flags.model : '';
|
|
499
|
+
if (!model && tty) model = (await ask(' Model id (e.g. llama3.1): ')).trim();
|
|
500
|
+
plan.baseUrl = baseUrl; plan.model = model || 'llama3.1';
|
|
501
|
+
let key = (flags['api-key'] && flags['api-key'] !== true) ? flags['api-key'] : '';
|
|
502
|
+
if (!key && tty) key = (await promptHidden(' API key if your endpoint needs one (blank for local): ')).trim();
|
|
503
|
+
config.plan = plan; U.writeJSON(p.config, config);
|
|
504
|
+
if (key) { writeEnvVar(target, 'OPENAI_API_KEY', key); ensureGitignored(target, '.env'); }
|
|
505
|
+
console.log(' ' + U.c.green('✓ custom provider set') + U.c.dim(` → ${baseUrl || '(no URL yet)'} · model ${plan.model} · powers System Plan, Ask & adversary.`));
|
|
506
|
+
} else {
|
|
507
|
+
const envVar = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
508
|
+
let key = (flags['api-key'] && flags['api-key'] !== true) ? flags['api-key'] : (process.env[envVar] || '');
|
|
509
|
+
if (!key && tty) key = (await promptHidden(` Paste your ${envVar} (hidden, stored in .env): `)).trim();
|
|
510
|
+
plan.model = provider === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o';
|
|
511
|
+
config.plan = plan; U.writeJSON(p.config, config);
|
|
512
|
+
if (key) {
|
|
513
|
+
writeEnvVar(target, envVar, key); ensureGitignored(target, '.env');
|
|
514
|
+
console.log(' ' + U.c.green(`✓ ${envVar} saved to .env`) + U.c.dim(' (gitignored) · powers System Plan, Ask & adversary.'));
|
|
515
|
+
} else {
|
|
516
|
+
console.log(' ' + U.c.yellow('• no key provided') + U.c.dim(` — add ${envVar}=… to .env later; the System Plan, Ask and adversary run once the key is present.`));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 6 ── Foundation seal posture: reveal tampering/corruption of the fixed core (secure by default).
|
|
522
|
+
await foundationPosturePrompt(p, config, flags, tty);
|
|
523
|
+
|
|
524
|
+
const cd = rel === '.' ? '' : `cd ${rel} && `;
|
|
525
|
+
console.log('\n' + U.c.bold('Done.') + ' Next: write/prune specs → ' + U.c.bold(`${cd}yay verify`) + ' → ' + U.c.bold('yay sign') + '.');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Ask (or take from flags) the foundation-seal posture at init/adopt, and seal now when there's
|
|
529
|
+
// already committed content to vouch for. Default GUARDED (reveal-not-block). Never fatal.
|
|
530
|
+
async function foundationPosturePrompt(p, config, flags, tty) {
|
|
531
|
+
try {
|
|
532
|
+
if (config && config.foundation) return; // already chosen (re-init)
|
|
533
|
+
let mode = (flags['foundation'] && flags['foundation'] !== true) ? String(flags['foundation']).toLowerCase()
|
|
534
|
+
: (flags['no-foundation'] ? 'off' : null);
|
|
535
|
+
if (!mode && tty) {
|
|
536
|
+
console.log('\n' + U.c.bold('Foundation seal') + U.c.dim(' — reveal any change to your project\'s FIXED core (Constitution, CI workflow, .gitignore, protocol files) so tampering or corruption never goes unnoticed. Ordinary code is unaffected.'));
|
|
537
|
+
console.log(' ' + U.c.dim('[1] Guarded (recommended) — drift warns [2] Strict — drift blocks the gate [3] Off'));
|
|
538
|
+
const a = (await ask(' Choose 1, 2 or 3 (Enter = Guarded): ')).trim();
|
|
539
|
+
mode = a === '3' ? 'off' : a === '2' ? 'strict' : 'guarded';
|
|
540
|
+
}
|
|
541
|
+
if (!mode) mode = 'guarded';
|
|
542
|
+
if (mode === 'off') { console.log(' ' + U.c.dim('• foundation seal off — enable later with `yay protect`.')); return; }
|
|
543
|
+
const tracked = trackedFiles(p.root);
|
|
544
|
+
if (!tracked || !tracked.length) {
|
|
545
|
+
console.log(' ' + U.c.yellow(`• foundation posture: ${mode}`) + U.c.dim(' — commit your core files, then run `yay protect` to create the seal.'));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// There is committed content to vouch for — seal it now (owner-signed).
|
|
549
|
+
const rlog = loadRoster(p);
|
|
550
|
+
if (!rlog || !rlog.events || !rlog.events.length) { console.log(' ' + U.c.dim('• run `yay protect` to seal once a trust root exists.')); return; }
|
|
551
|
+
const seal = F.buildSeal(p.root, tracked, {});
|
|
552
|
+
const ev = { id: rosterMod.nextEventId(rlog), type: 'foundation', mode, seal, prev: rlog.events[rlog.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
553
|
+
const summary = { title: 'Seal the project foundation', rows: [{ k: 'mode', v: mode }, { k: 'files', v: Object.keys(seal.files).length + ' core file(s)' }], warn: 'You are vouching the current fixed-core files are correct; later changes are revealed at verify.' };
|
|
554
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
555
|
+
if (!signed) { console.log(' ' + U.c.yellow(`• foundation posture: ${mode}`) + U.c.dim(' — not signed now; run `yay protect` to seal.')); return; }
|
|
556
|
+
rlog.events.push(signed); U.writeJSON(rosterPath(p), rlog);
|
|
557
|
+
config.foundation = mode; U.writeJSON(p.config, config);
|
|
558
|
+
console.log(' ' + U.c.green(`✓ foundation sealed (${mode})`) + U.c.dim(` — ${Object.keys(seal.files).length} core file(s) watched.`));
|
|
559
|
+
} catch (e) { console.log(' ' + U.c.dim('• foundation seal skipped: ' + (e && e.message || e) + ' — run `yay protect` later.')); }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Append or update KEY=value in <dir>/.env without disturbing other lines.
|
|
563
|
+
function writeEnvVar(dir, key, value) {
|
|
564
|
+
const envPath = path.join(dir, '.env');
|
|
565
|
+
let txt = ''; try { txt = fs.readFileSync(envPath, 'utf8'); } catch (_) {}
|
|
566
|
+
const line = key + '=' + value;
|
|
567
|
+
const re = new RegExp('^' + key + '=.*$', 'm');
|
|
568
|
+
txt = re.test(txt) ? txt.replace(re, line) : (txt + (txt && !txt.endsWith('\n') ? '\n' : '') + line + '\n');
|
|
569
|
+
fs.writeFileSync(envPath, txt, { mode: 0o600 });
|
|
570
|
+
}
|
|
571
|
+
function ensureGitignored(dir, entry) {
|
|
572
|
+
const gi = path.join(dir, '.gitignore');
|
|
573
|
+
let txt = ''; try { txt = fs.readFileSync(gi, 'utf8'); } catch (_) {}
|
|
574
|
+
if (txt.split(/\r?\n/).some((l) => l.trim() === entry)) return;
|
|
575
|
+
fs.writeFileSync(gi, txt + (txt && !txt.endsWith('\n') ? '\n' : '') + entry + '\n');
|
|
576
|
+
}
|
|
577
|
+
// Make the append-only ledgers auto-merge (git's built-in `union` driver) instead of textually
|
|
578
|
+
// conflicting. Per-cell trust is matched by specHash (order-independent), so a unioned lock.json
|
|
579
|
+
// verifies fine; the roster is a chained governance log and is left to merge deliberately. Returns
|
|
580
|
+
// true if anything was added. Idempotent.
|
|
581
|
+
function ensureLedgerMergeAttrs(root) {
|
|
582
|
+
const ga = path.join(root, '.gitattributes');
|
|
583
|
+
let txt = ''; try { txt = fs.readFileSync(ga, 'utf8'); } catch (_) {}
|
|
584
|
+
const want = ['.yaylayer/lock.json merge=union', '.yaylayer/attest.json merge=union', '.yaylayer/rejections.json merge=union'];
|
|
585
|
+
let changed = false;
|
|
586
|
+
for (const l of want) { if (!txt.split(/\r?\n/).some((x) => x.trim() === l)) { txt += (txt && !txt.endsWith('\n') ? '\n' : '') + l + '\n'; changed = true; } }
|
|
587
|
+
if (changed) fs.writeFileSync(ga, txt);
|
|
588
|
+
return changed;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function cmdGate(flags, positional) {
|
|
592
|
+
const target = path.resolve(positional[0] || process.cwd());
|
|
593
|
+
if (!fs.existsSync(target)) return fail(`no such directory: ${target}`);
|
|
594
|
+
const scope = (flags.scope && flags.scope !== true) ? flags.scope : '';
|
|
595
|
+
const pkg = (flags.pkg && flags.pkg !== true) ? flags.pkg : 'yay-layer';
|
|
596
|
+
// Pin the trust root in CI so a swapped roster fails there too. Default to the
|
|
597
|
+
// current project's root fingerprint if we can read it.
|
|
598
|
+
let root = (flags.root && flags.root !== true) ? flags.root : '';
|
|
599
|
+
if (!root) { try { const d = rosterMod.deriveRoster(U.readJSON(path.join(target, '.yaylayer', 'roster.json'), null) || {}); if (d.rootFp) root = d.rootFp; } catch (_) {} }
|
|
600
|
+
const platform = gate.normPlatform((flags.for && flags.for !== true) ? flags.for : (flags.platform && flags.platform !== true ? flags.platform : 'github'));
|
|
601
|
+
const opts = { force: !!flags.force, scope, pkg, root, platform };
|
|
602
|
+
console.log(' ' + U.c.dim('platform: ') + U.c.bold(platform) + U.c.dim(` (change with --for github|azure|gitlab|bitbucket|gitea|gerrit)`));
|
|
603
|
+
|
|
604
|
+
const w = gate.writeWorkflow(target, opts);
|
|
605
|
+
const wmark = w.action === 'skipped' ? U.c.dim('• skipped (exists — use --force) ') : U.c.green('✓ ' + w.action + ' ');
|
|
606
|
+
console.log(' ' + wmark + w.path);
|
|
607
|
+
for (const ex of (w.extra || [])) {
|
|
608
|
+
const em = ex.action === 'skipped' ? U.c.dim('• skipped (exists — use --force) ') : U.c.green('✓ ' + ex.action + ' ');
|
|
609
|
+
console.log(' ' + em + ex.path);
|
|
610
|
+
}
|
|
611
|
+
if (ensureLedgerMergeAttrs(target)) console.log(' ' + U.c.green('✓ .gitattributes') + U.c.dim(' — ledgers set to auto-merge (union). Commit it.'));
|
|
612
|
+
|
|
613
|
+
if (flags.hook) {
|
|
614
|
+
const h = gate.writeHook(target, opts);
|
|
615
|
+
if (h.action === 'no-git') console.log(' ' + U.c.yellow('• pre-push hook: not a git repo (run `git init` first)'));
|
|
616
|
+
else if (h.action === 'skipped') console.log(' ' + U.c.dim('• pre-push hook skipped (exists — use --force)'));
|
|
617
|
+
else console.log(' ' + U.c.green('✓ created ') + h.path + U.c.dim(' (local feedback; bypass with git push --no-verify)'));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (pkg === 'yay-layer') console.log('\n' + U.c.dim('note: yay-layer isn\'t on npm yet — until it is, use ') + U.c.bold('yay gate --pkg github:jonas-developer/yay-layer') + U.c.dim(' or edit the install line.'));
|
|
621
|
+
console.log('\n' + gate.branchProtectionSteps(platform));
|
|
622
|
+
if (!flags.hook) console.log('\n' + U.c.dim('Tip: `yay gate --hook` also installs a local pre-push gate for solo/offline work.'));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function cmdConstitution(flags, positional) {
|
|
626
|
+
const target = path.resolve(positional[0] || process.cwd());
|
|
627
|
+
if (flags.list || flags.l) {
|
|
628
|
+
console.log(U.c.bold('YayLayer can instruct these AI harnesses:'));
|
|
629
|
+
for (const h of HARNESSES) console.log(' ' + U.c.accent(h.key.padEnd(9)) + h.path.padEnd(34) + U.c.dim(h.note));
|
|
630
|
+
console.log(U.c.dim('\nWrite one or more: ') + U.c.bold('yay constitution --for claude,agents,cursor') + U.c.dim(' (or --for all)'));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const spec = (typeof flags.for === 'string') ? flags.for : (flags.for === true ? 'all' : null);
|
|
634
|
+
if (!spec) {
|
|
635
|
+
console.log('Pick harness(es): ' + U.c.bold('yay constitution --for claude,agents') + U.c.dim(' · list them with ') + U.c.bold('--list'));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const cMethod = signMethodOf(U.readJSON(U.paths(target).config, null));
|
|
639
|
+
for (const r of writeConstitution(target, resolveKeys(spec), cMethod)) {
|
|
640
|
+
if (r.error) console.log(U.c.red(' ✗ ') + r.key + ' — ' + r.error);
|
|
641
|
+
else console.log(' ' + U.c.green('✓ ') + r.action.padEnd(9) + ' ' + r.path + U.c.dim(` (${r.label})`));
|
|
642
|
+
}
|
|
643
|
+
console.log(U.c.dim('\nThe text between the YAYLAYER markers is managed by yay; anything outside it is yours. Commit these files.'));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function cmdKeygen(flags) {
|
|
647
|
+
const { p, config } = loadState();
|
|
648
|
+
if (!config) return fail('run `yay init` first');
|
|
649
|
+
const name = (flags.name && flags.name !== true) ? flags.name : null;
|
|
650
|
+
if (!name) return fail('give yourself a name: yay keygen --name alice');
|
|
651
|
+
if (fs.existsSync(path.join(p.keys, `${name}.keystore`))) return fail(`a local key for "${name}" already exists`);
|
|
652
|
+
const pass = await getPassphrase(flags, `Set a passphrase to encrypt ${name}'s key (you'll re-enter it each time you sign)`);
|
|
653
|
+
if (!pass || pass.length < 6) return fail('passphrase must be at least 6 characters');
|
|
654
|
+
const ksPath = createKey(p, config, name, pass);
|
|
655
|
+
saveLocalSigner(p, name); // this machine now signs as "name" by default
|
|
656
|
+
console.log(U.c.green(`✓ key created for "${name}"`));
|
|
657
|
+
console.log(' public key → roster in .yaylayer/config.json');
|
|
658
|
+
console.log(' private key → ' + U.c.dim(path.relative(process.cwd(), ksPath)) + U.c.dim(' (gitignored, encrypted)'));
|
|
659
|
+
console.log(U.c.yellow('\n ⚠ Local keystore') + U.c.dim(' — this key is a file on THIS machine (passphrase-encrypted). It has no'));
|
|
660
|
+
console.log(U.c.dim(' recovery phrase; if you lose it, keep a 2nd owner key. For a phone key with a'));
|
|
661
|
+
console.log(U.c.dim(' 24-word recovery backup that never touches this machine, use ') + U.c.bold('yay pair') + U.c.dim('. Never commit .yaylayer/keys/.'));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── route phone requests through a RUNNING dashboard (one origin, scan-once) ──
|
|
665
|
+
function dashboardReg(p) {
|
|
666
|
+
const d = U.readJSON(path.join(path.dirname(p.config), 'dashboard.json'), null);
|
|
667
|
+
return d && d.port ? d : null;
|
|
668
|
+
}
|
|
669
|
+
async function dfetch(info, pathname, opts) {
|
|
670
|
+
const base = `${info.scheme}://127.0.0.1:${info.port}`;
|
|
671
|
+
const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
672
|
+
if (info.scheme === 'https') process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // self-signed localhost
|
|
673
|
+
try { return await fetch(base + pathname, opts); }
|
|
674
|
+
finally { if (info.scheme === 'https') { if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev; } }
|
|
675
|
+
}
|
|
676
|
+
// → { result, info } from the phone via the dashboard, { busy } if one's in flight,
|
|
677
|
+
// or null if there's no live dashboard (caller falls back to the ephemeral server).
|
|
678
|
+
async function routeThroughDashboard(p, mode, payload) {
|
|
679
|
+
const info = dashboardReg(p); if (!info) return null;
|
|
680
|
+
let ping; try { ping = await dfetch(info, '/api/ping').then((r) => r.json()); } catch (_) { return null; }
|
|
681
|
+
if (!ping || ping.yay !== 'dashboard') return null;
|
|
682
|
+
const rq = await dfetch(info, '/api/request', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode, ...payload }) });
|
|
683
|
+
if (rq.status === 409) { console.log(U.c.yellow(' a request is already awaiting the phone on the dashboard — finish it first.')); return { busy: true, info }; }
|
|
684
|
+
console.log('\n' + U.c.bold('→ sent to your phone') + U.c.dim(` — approve on the dashboard you already have open (${info.phoneUrl || info.scheme + '://<this-mac>:' + info.port + '/phone'}). Ctrl-C to cancel.`));
|
|
685
|
+
for (;;) {
|
|
686
|
+
let r; try { r = await dfetch(info, '/api/result').then((x) => x.json()); } catch (_) { return null; }
|
|
687
|
+
if (r.gone) { console.log(U.c.red(' the dashboard request was cancelled.')); return null; }
|
|
688
|
+
if (r.result) return { result: r.result, info };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function dashboardFinal(info, final) { try { await dfetch(info, '/api/final', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ final }) }); } catch (_) {} }
|
|
692
|
+
|
|
693
|
+
// ── route phone requests through the HOSTED relay (relay.yaylayer.com), end-to-end
|
|
694
|
+
// encrypted. Chosen when the project's transport is 'relay' (never for plain LAN/local).
|
|
695
|
+
// The relay is untrusted: it only shuttles opaque ciphertext, so the laptop verifies
|
|
696
|
+
// every answer itself here (the relay can't).
|
|
697
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
698
|
+
function relayBase(flags) {
|
|
699
|
+
return (flags && flags['relay-url'] && flags['relay-url'] !== true) ? String(flags['relay-url']).replace(/\/$/, '')
|
|
700
|
+
: (process.env.YAY_RELAY_URL ? String(process.env.YAY_RELAY_URL).replace(/\/$/, '') : 'https://relay.yaylayer.com');
|
|
701
|
+
}
|
|
702
|
+
function phoneTransport(config, flags) {
|
|
703
|
+
if (flags && flags.relay) return 'relay';
|
|
704
|
+
if (flags && flags.lan) return 'lan';
|
|
705
|
+
return (config && config.transport === 'relay') ? 'relay' : 'lan';
|
|
706
|
+
}
|
|
707
|
+
// The relay session (channel + E2E key) persists so the human scans ONCE; later requests
|
|
708
|
+
// go to the same channel and appear on the phone page already open. Secret → gitignored.
|
|
709
|
+
function ensureRelaySession(p, flags) {
|
|
710
|
+
const regPath = path.join(path.dirname(p.config), 'relay.json');
|
|
711
|
+
let reg = U.readJSON(regPath, null); let fresh = false;
|
|
712
|
+
if (!reg || !reg.channel || !reg.key) {
|
|
713
|
+
reg = { base: relayBase(flags), channel: E2E.newChannel(), key: E2E.b64url(E2E.newKey()), createdAt: new Date().toISOString() };
|
|
714
|
+
U.writeJSON(regPath, reg); ensureGitignored(p.root, '.yaylayer/relay.json'); fresh = true;
|
|
715
|
+
}
|
|
716
|
+
const base = relayBase(flags) !== 'https://relay.yaylayer.com' ? relayBase(flags) : (reg.base || relayBase(flags));
|
|
717
|
+
return { base, channel: reg.channel, keyBytes: E2E.fromB64url(reg.key), url: base + '/#' + reg.channel + '.' + reg.key, fresh };
|
|
718
|
+
}
|
|
719
|
+
function relayFetch(sess, pathname, opts) {
|
|
720
|
+
opts = opts || {}; opts.headers = Object.assign({ 'content-type': 'application/json' }, opts.headers || {});
|
|
721
|
+
return fetch(sess.base + pathname, opts);
|
|
722
|
+
}
|
|
723
|
+
async function relayFinal(sess, final) { try { await relayFetch(sess, '/api/final', { method: 'POST', body: JSON.stringify({ ch: sess.channel, blob: E2E.seal(sess.keyBytes, final) }) }); } catch (_) {} }
|
|
724
|
+
// Verify the phone's decrypted answer exactly as the dashboard's verifySubmit does, so
|
|
725
|
+
// callers get the SAME result shape whether they went via LAN or the relay.
|
|
726
|
+
function verifyRelayAnswer(mode, wire, b, expectPubs) {
|
|
727
|
+
if (mode === 'pair') {
|
|
728
|
+
const { name, pubB64, proof } = b || {};
|
|
729
|
+
if (!name || !pubB64 || !proof) return { error: 'phone answer missing name/pubB64/proof' };
|
|
730
|
+
if (wire.genesis) {
|
|
731
|
+
const ev = { ...wire.genesis, name: String(name), pub: pubB64, by: String(name) };
|
|
732
|
+
if (!C.verify(U.canonical(ev), proof, pubB64)) return { error: 'genesis self-signature failed' };
|
|
733
|
+
return { result: { name: String(name), pubB64, code: phone.confirmCode(pubB64), genesisEvent: { ...ev, signature: proof } } };
|
|
734
|
+
}
|
|
735
|
+
if (!C.verify(wire.challenge, proof, pubB64)) return { error: 'key possession proof failed' };
|
|
736
|
+
return { result: { name: String(name), pubB64, proof, code: phone.confirmCode(pubB64) } };
|
|
737
|
+
}
|
|
738
|
+
// Send back (§5): an approval declined on the phone, with an optional note and/or corrected tags.
|
|
739
|
+
if (mode === 'approve' && b && b.rejected) return { result: { rejected: true, reason: String(b.reason || '').trim(), tags: Array.isArray(b.tags) ? b.tags : undefined } };
|
|
740
|
+
if (!b || !b.signature) return { error: 'phone answer missing signature' };
|
|
741
|
+
let target = wire.approval || wire.event;
|
|
742
|
+
const editedBrief = (b.brief !== undefined && wire.approval && wire.approval.brief);
|
|
743
|
+
if (editedBrief) target = { ...wire.approval, brief: { ...wire.approval.brief, text: String(b.brief) } };
|
|
744
|
+
if (!(expectPubs || []).some((pub) => pub && C.verify(U.canonical(target), b.signature, pub))) return { error: 'the phone signature did not verify against an authorized key' };
|
|
745
|
+
return { result: editedBrief ? { signature: b.signature, brief: String(b.brief) } : { signature: b.signature } };
|
|
746
|
+
}
|
|
747
|
+
async function routeThroughRelay(p, mode, payload, flags) {
|
|
748
|
+
const sess = ensureRelaySession(p, flags);
|
|
749
|
+
const wire = { mode };
|
|
750
|
+
['approval', 'event', 'summary', 'challenge', 'genesis', 'project', 'signer', 'signerPubs'].forEach((k) => { if (payload[k] !== undefined) wire[k] = payload[k]; });
|
|
751
|
+
try {
|
|
752
|
+
const rq = await relayFetch(sess, '/api/request', { method: 'POST', body: JSON.stringify({ ch: sess.channel, blob: E2E.seal(sess.keyBytes, wire) }) });
|
|
753
|
+
if (rq.status === 409) { const j = await rq.json().catch(() => ({})); console.log(U.c.yellow(' ' + (j.error || 'a request is already awaiting approval on this phone — finish or cancel it first.'))); return null; }
|
|
754
|
+
if (!rq.ok) { console.log(U.c.red(' relay rejected the request (HTTP ' + rq.status + ').')); return null; }
|
|
755
|
+
} catch (e) { console.log(U.c.red(' could not reach the relay (' + sess.base + '): ' + (e && e.message || e))); return null; }
|
|
756
|
+
if (sess.fresh) {
|
|
757
|
+
console.log('\n' + U.c.bold('→ scan ONCE to sign on your phone (over the relay):'));
|
|
758
|
+
printQR(sess.url);
|
|
759
|
+
console.log(' ' + U.c.accent(sess.url));
|
|
760
|
+
console.log(U.c.dim(' later requests appear on that page automatically. Ctrl-C to cancel.'));
|
|
761
|
+
} else {
|
|
762
|
+
console.log('\n' + U.c.bold('→ sent to your phone') + U.c.dim(' — approve on the relay page you already have open. Ctrl-C to cancel.'));
|
|
763
|
+
}
|
|
764
|
+
const expectPubs = payload.expectPubB64 || payload.ownerPubs || [];
|
|
765
|
+
for (;;) {
|
|
766
|
+
let r; try { r = await relayFetch(sess, '/api/result?ch=' + encodeURIComponent(sess.channel)).then((x) => x.json()); } catch (_) { await sleepMs(1500); continue; }
|
|
767
|
+
if (r && r.state === 'answered') {
|
|
768
|
+
const b = E2E.open(sess.keyBytes, r.blob);
|
|
769
|
+
if (!b) { console.log(U.c.red(' could not decrypt the phone answer — key mismatch (re-pair to reset the channel).')); return null; }
|
|
770
|
+
const out = verifyRelayAnswer(mode, wire, b, expectPubs);
|
|
771
|
+
if (out.error) { await relayFinal(sess, { ok: false, reason: out.error }); console.log(U.c.red(' ' + out.error + ' — nothing was written.')); return null; }
|
|
772
|
+
return { result: out.result, sess };
|
|
773
|
+
}
|
|
774
|
+
await sleepMs(1500);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// The per-Cell summary the phone shows (state colour, intent, spec, spec-diff).
|
|
779
|
+
function signSummary(p, config, lock, manifest, items) {
|
|
780
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false });
|
|
781
|
+
const SEALCOLORS = { GREEN: '#1f9d57', YELLOW: '#c9860f', RED: '#cf4436', UNSIGNED: '#7f8796', PINK: '#e0559b' };
|
|
782
|
+
return Object.keys(items).map((id) => {
|
|
783
|
+
const c = manifest.cells[id]; const r = (verified.results[id] || {});
|
|
784
|
+
const auto = !!(r.trust && r.trust.auto);
|
|
785
|
+
return {
|
|
786
|
+
id, unit: c.unitName || (c.spec && c.spec.unit) || '', intent: (c.spec && c.spec.intent) || '',
|
|
787
|
+
state: r.state || 'UNSIGNED', color: SEALCOLORS[r.state] || '#7f8796',
|
|
788
|
+
file: c.file || '', line: c.line || 0, spec: c.spec || {},
|
|
789
|
+
// WYSIWYS: the EXACT normalized spec bytes whose sha256 IS the signed specHash. The phone
|
|
790
|
+
// recomputes sha256(block) and refuses to sign unless it equals approval.items[id] — so a
|
|
791
|
+
// compromised laptop can't show one spec and bind the signature to another.
|
|
792
|
+
block: c.specBlock || '',
|
|
793
|
+
notes: (r.notes || []).map((nt) => ({ level: nt.level, text: nt.text })),
|
|
794
|
+
diff: specDiffForCell(p.root, c),
|
|
795
|
+
// Ratification only: the code ALREADY exists (built unattended under a grant), so show
|
|
796
|
+
// it for review. Normal forward-signing has no code yet, so this stays undefined there.
|
|
797
|
+
auto, grant: auto ? (r.trust.grant || null) : null, code: auto ? (c.unitBody || '') : null,
|
|
798
|
+
};
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const pendingDir = (p) => path.join(path.dirname(p.config), 'pending');
|
|
802
|
+
const requestsPath = (p) => path.join(path.dirname(p.config), 'requests.json');
|
|
803
|
+
|
|
804
|
+
// `yay requests` — the AI's inbox of plain human requests queued from the dashboard's
|
|
805
|
+
// "Request a change" button. Each is a normal request to turn into a polished Brief + Cells.
|
|
806
|
+
function cmdRequests(flags, positional) {
|
|
807
|
+
const { p, config } = loadState();
|
|
808
|
+
if (!config) return fail('run `yay init` first');
|
|
809
|
+
const rp = requestsPath(p);
|
|
810
|
+
const log = U.readJSON(rp, null) || { project: config.project, requests: [] };
|
|
811
|
+
const reqs = log.requests || [];
|
|
812
|
+
const sub = positional[0];
|
|
813
|
+
if (sub === 'done' || sub === 'clear') {
|
|
814
|
+
const id = positional[1];
|
|
815
|
+
const before = reqs.length;
|
|
816
|
+
log.requests = (id && id !== 'all') ? reqs.filter((r) => r.id !== id) : [];
|
|
817
|
+
U.writeJSON(rp, log);
|
|
818
|
+
console.log(U.c.green(`✓ cleared ${before - log.requests.length} request(s).`));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const pending = reqs.filter((r) => r.status !== 'done');
|
|
822
|
+
if (!pending.length) { console.log(U.c.dim('no pending requests. (The dashboard "Request a change" button queues them here.)')); return; }
|
|
823
|
+
console.log(U.c.bold(`Pending requests (${pending.length})`) + U.c.dim(' — queued from the dashboard:'));
|
|
824
|
+
for (const r of pending) {
|
|
825
|
+
console.log(' ' + U.c.accent(r.id) + U.c.dim(' · ' + String(r.at).slice(0, 16).replace('T', ' ')));
|
|
826
|
+
console.log(' ' + r.text);
|
|
827
|
+
}
|
|
828
|
+
console.log('\n' + U.c.dim('AI: treat each as a normal request — draft a polished Brief + Cells and present it for signing,'));
|
|
829
|
+
console.log(U.c.dim(' then run ') + U.c.bold('yay requests done <id>') + U.c.dim(' once it is signed (or folded into a change-set).'));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// The signer can Accept, or Send back (with an optional note) — never silently reword the
|
|
833
|
+
// Brief on the phone (a Brief change must pull its Cells with it, and the phone can't do
|
|
834
|
+
// that). This prints the send-back for the AI reading the CLI output, encoding how to act.
|
|
835
|
+
function reportSendBack(result, name) {
|
|
836
|
+
if (!result || !result.rejected) return false;
|
|
837
|
+
const note = (result.reason && String(result.reason).trim()) || '';
|
|
838
|
+
const tags = Array.isArray(result.tags) ? result.tags.filter(Boolean) : null;
|
|
839
|
+
console.log('\n' + U.c.yellow(`↩ ${name} sent it back — NOT signed.`));
|
|
840
|
+
if (tags && tags.length) {
|
|
841
|
+
console.log(' ' + U.c.bold('corrected tags: ') + U.c.accent(tags.join(', ')));
|
|
842
|
+
console.log(' ' + U.c.dim('re-issue the Brief with these tags: ') + U.c.bold(`yay sign --tags "${tags.join(',')}"`) + U.c.dim(' — then re-present. (If a new tag reveals a different concern, consider splitting the Brief.)'));
|
|
843
|
+
}
|
|
844
|
+
if (note) {
|
|
845
|
+
console.log(' ' + U.c.bold('their note: ') + U.c.accent(note));
|
|
846
|
+
console.log(' ' + U.c.dim('Reconcile the whole change-set per this note — the Brief and its Cells move together: a small'));
|
|
847
|
+
console.log(' ' + U.c.dim('correction → adjust the affected Cell spec(s) + Brief and re-present for approval; a fundamental'));
|
|
848
|
+
console.log(' ' + U.c.dim('one → treat it as a new request and rebuild the change-set from scratch. Then sign again.'));
|
|
849
|
+
} else if (!(tags && tags.length)) {
|
|
850
|
+
console.log(' ' + U.c.dim('No note was given — do NOT guess. Ask the human in chat how to proceed before changing anything.'));
|
|
851
|
+
}
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Cross-signer delegation: seal the request to the target's INBOX (over the relay) and
|
|
856
|
+
// return a request id. Fire-and-return — the Cells stay Unsigned until they approve;
|
|
857
|
+
// collect their signature later with `yay sign --check`.
|
|
858
|
+
async function sendToInbox(p, config, lock, manifest, items, approval, name, flags) {
|
|
859
|
+
const targetPub = U.pubKeysOf(config.signers[name])[0];
|
|
860
|
+
if (!targetPub) return fail(`no key on record for "${name}" — is that signer enrolled?`);
|
|
861
|
+
const summary = signSummary(p, config, lock, manifest, items);
|
|
862
|
+
const reqId = E2E.newChannel(); // url-safe id (also unguessable)
|
|
863
|
+
const replyKey = E2E.b64url(E2E.newKey()); // symmetric key for the sealed reply
|
|
864
|
+
const wire = { mode: 'approve', approval, summary, tagPool: (tagsMod.loadTags(p) || { tags: [] }).tags, project: config.project, signer: name, signerPubs: U.pubKeysOf(config.signers[name]), replyKey };
|
|
865
|
+
const sealed = E2E.sealTo(targetPub, wire); // only `name` can open it
|
|
866
|
+
const inbox = E2E.inboxChannel(targetPub);
|
|
867
|
+
const base = relayBase(flags);
|
|
868
|
+
try {
|
|
869
|
+
const r = await fetch(base + '/api/inbox', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ch: inbox, id: reqId, blob: sealed }) });
|
|
870
|
+
if (r.status === 429) return fail(`${name}'s inbox is full — they have too many pending requests. Try again later.`);
|
|
871
|
+
if (!r.ok) return fail('the relay rejected the request (HTTP ' + r.status + ').');
|
|
872
|
+
} catch (e) { return fail('could not reach the relay (' + base + '): ' + ((e && e.message) || e)); }
|
|
873
|
+
const dir = pendingDir(p); fs.mkdirSync(dir, { recursive: true });
|
|
874
|
+
U.writeJSON(path.join(dir, reqId + '.json'), { id: reqId, name, inbox, replyKey, approval, cells: Object.keys(items), at: new Date().toISOString() });
|
|
875
|
+
ensureGitignored(p.root, '.yaylayer/pending/');
|
|
876
|
+
console.log('\n' + U.c.green(`→ sent to ${name}'s inbox`) + U.c.dim(` — request ${reqId}. Pending their approval (fire-and-return).`));
|
|
877
|
+
console.log(U.c.dim(` the ${Object.keys(items).length} Cell(s) stay Unsigned — the gate blocks them — until ${name} signs.`));
|
|
878
|
+
console.log(U.c.dim(' collect it later with ') + U.c.bold(`yay sign --check ${reqId}`) + U.c.dim(' (or ') + U.c.bold('yay sign --check') + U.c.dim(' for all pending).'));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Collect the reply to one (or all) pending cross-signer requests and write the seal.
|
|
882
|
+
async function cmdSignCheck(flags, positional) {
|
|
883
|
+
const { p, config } = loadState();
|
|
884
|
+
if (!config) return fail('run `yay init` first');
|
|
885
|
+
const dir = pendingDir(p);
|
|
886
|
+
const target = (positional && positional[0]) || (flags.check && flags.check !== true ? flags.check : null);
|
|
887
|
+
let ids;
|
|
888
|
+
if (target) ids = [target];
|
|
889
|
+
else { try { ids = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)); } catch (_) { ids = []; } }
|
|
890
|
+
if (!ids.length) { console.log(U.c.dim('no pending cross-signer requests.')); return; }
|
|
891
|
+
const base = relayBase(flags);
|
|
892
|
+
for (const id of ids) {
|
|
893
|
+
const pend = U.readJSON(path.join(dir, id + '.json'), null);
|
|
894
|
+
if (!pend) { console.log(U.c.yellow(` ${id}: no local record — skipping.`)); continue; }
|
|
895
|
+
let resp; try { resp = await fetch(base + '/api/inbox?ch=' + encodeURIComponent(pend.inbox) + '&id=' + encodeURIComponent(id)).then((r) => r.json()); }
|
|
896
|
+
catch (e) { console.log(U.c.red(` ${id}: relay unreachable (${(e && e.message) || e}).`)); continue; }
|
|
897
|
+
if (!resp || resp.pending || !resp.reply) { console.log(U.c.dim(` ${id} (${pend.name}): still pending their approval.`)); continue; }
|
|
898
|
+
const ans = E2E.open(E2E.fromB64url(pend.replyKey), resp.reply);
|
|
899
|
+
if (!ans) { console.log(U.c.red(` ${id}: could not decrypt the reply (key mismatch).`)); continue; }
|
|
900
|
+
if (ans.rejected) {
|
|
901
|
+
fs.unlinkSync(path.join(dir, id + '.json'));
|
|
902
|
+
try { await fetch(base + '/api/inbox', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ch: pend.inbox, id, remove: true }) }); } catch (_) {}
|
|
903
|
+
reportSendBack(ans, pend.name); continue;
|
|
904
|
+
}
|
|
905
|
+
const approval = pend.approval;
|
|
906
|
+
if (ans.brief !== undefined && approval.brief) approval.brief.text = String(ans.brief);
|
|
907
|
+
const pubs = U.pubKeysOf(config.signers[pend.name]);
|
|
908
|
+
if (!ans.signature || !pubs.some((pub) => C.verify(U.canonical(approval), ans.signature, pub))) { console.log(U.c.red(` ${id}: signature did not verify against ${pend.name}'s key — NOT written.`)); continue; }
|
|
909
|
+
approval.signature = ans.signature;
|
|
910
|
+
const st = loadState(); st.lock.approvals = st.lock.approvals || []; st.lock.approvals.push(approval); U.writeJSON(st.p.lock, st.lock);
|
|
911
|
+
try { fs.unlinkSync(path.join(dir, id + '.json')); } catch (_) {}
|
|
912
|
+
try { await fetch(base + '/api/inbox', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ch: pend.inbox, id, remove: true }) }); } catch (_) {}
|
|
913
|
+
console.log(U.c.green(` ✓ ${pend.name} signed`) + U.c.dim(` — seal ${approval.id} written (${(pend.cells || []).length} Cell(s)). Commit .yaylayer/lock.json.`));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// `yay inbox` — print YOUR on-duty relay link. Leave it open on your phone to receive
|
|
918
|
+
// requests others address to you with `yay sign --name "<you>"`.
|
|
919
|
+
function cmdInbox(flags) {
|
|
920
|
+
const { p, config } = loadState();
|
|
921
|
+
if (!config) return fail('run `yay init` first');
|
|
922
|
+
const me = loadLocalSigner(p) || config.owners[0];
|
|
923
|
+
const pub = U.pubKeysOf(config.signers[me])[0];
|
|
924
|
+
if (!pub) return fail(`no key for "${me}" on this machine — run \`yay pair\` first.`);
|
|
925
|
+
const url = relayBase(flags) + '/#inbox=' + encodeURIComponent(pub);
|
|
926
|
+
console.log('\n' + U.c.green(`✓ your inbox — ${me}`) + U.c.dim(' — open on your phone and leave it on-duty:'));
|
|
927
|
+
console.log(' ' + U.c.accent(url));
|
|
928
|
+
printQR(url);
|
|
929
|
+
console.log(U.c.dim(' requests others address to you (') + U.c.bold('yay sign --name "' + me + '"') + U.c.dim(') appear here; approve them like any sign.'));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Local signing is a terminal action, so bring the phone's "see what you're signing" moment to the CLI:
|
|
933
|
+
// print each Cell + its changes-vs-last-committed-spec diff (the SAME diff the phone shows), the Brief,
|
|
934
|
+
// and its verifier state, before the key is unlocked. Skipped for non-TTY / --yes (scripts, CI, ratify).
|
|
935
|
+
function printSignReview(summary, name, brief) {
|
|
936
|
+
console.log('\n' + U.c.bold('Review before signing') + U.c.dim(` — ${summary.length} Cell(s) as "${name}"`));
|
|
937
|
+
if (brief && brief.text) console.log(' ' + U.c.dim('brief: ') + U.c.accent(brief.text) + (brief.tags && brief.tags.length ? U.c.dim(' [' + brief.tags.join(', ') + ']') : ''));
|
|
938
|
+
for (const c of summary) {
|
|
939
|
+
console.log(' ' + U.c.yellow('•') + ' ' + U.c.bold(c.id) + U.c.dim(' · ' + (c.unit || '')) + ' ' + ratifyStateChip(c.state) + (c.intent ? U.c.dim(' — ' + c.intent) : ''));
|
|
940
|
+
if (c.diff && c.diff.length) {
|
|
941
|
+
console.log(' ' + U.c.dim('changes vs last committed spec:'));
|
|
942
|
+
for (const d of c.diff) {
|
|
943
|
+
if (d.t === '+') console.log(U.c.green(' + ' + d.text));
|
|
944
|
+
else if (d.t === '-') console.log(U.c.red(' - ' + d.text));
|
|
945
|
+
else console.log(U.c.dim(' ' + d.text));
|
|
946
|
+
}
|
|
947
|
+
} else {
|
|
948
|
+
console.log(' ' + U.c.dim('(new Cell — no prior signed spec to compare)'));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function cmdSign(flags, positional) {
|
|
954
|
+
const { p, config, lock } = loadState();
|
|
955
|
+
if (!config) return fail('run `yay init` first');
|
|
956
|
+
if (flags.check !== undefined) return cmdSignCheck(flags, positional);
|
|
957
|
+
// Default to THIS machine's own signer identity (set at pair/keygen), not the project
|
|
958
|
+
// owner — so a teammate's `yay sign` signs as themselves, matching the key on their phone.
|
|
959
|
+
let name = (flags.name && flags.name !== true) ? flags.name : null;
|
|
960
|
+
if (!name) { const local = loadLocalSigner(p); name = (local && U.pubKeysOf(config.signers[local]).length) ? local : config.owners[0]; }
|
|
961
|
+
if (!name || !U.pubKeysOf(config.signers[name]).length) return fail(`unknown signer "${name}" — run \`yay keygen --name ${name || '<you>'}\` or \`yay pair\``);
|
|
962
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
963
|
+
let ids = Object.keys(manifest.cells);
|
|
964
|
+
if (flags.cell && flags.cell !== true) ids = String(flags.cell).split(',').map((s) => s.trim());
|
|
965
|
+
if (!ids.length) return fail('no Cells to sign');
|
|
966
|
+
const items = {};
|
|
967
|
+
for (const id of ids) {
|
|
968
|
+
if (!manifest.cells[id]) { console.log(U.c.yellow(` skip ${id}: not found`)); continue; }
|
|
969
|
+
items[id] = manifest.cells[id].specHash;
|
|
970
|
+
// P1: archive the spec block AS SIGNED, content-addressed by its specHash, so the
|
|
971
|
+
// "as signed" spec is always reconstructable independent of git. Shared by the normal,
|
|
972
|
+
// Autopilot, and ratify sign paths (they all flow through this `items`).
|
|
973
|
+
if (manifest.cells[id].specBlock) O.putObject(p.root, manifest.cells[id].specBlock);
|
|
974
|
+
}
|
|
975
|
+
// ── Tag-plan gate (Standard §5) ── the AI picks a Brief's tags FROM the human's tag
|
|
976
|
+
// plan, so signing is refused while the plan is unfinished: unrelabeled "Custom N"
|
|
977
|
+
// placeholders, or fewer than MIN_PLAN_TAGS unique tags. And adopted (DERIVED) Cells
|
|
978
|
+
// may not be signed before a tag plan exists at all — the adoption wave is exactly
|
|
979
|
+
// when per-concern tagging matters most.
|
|
980
|
+
{
|
|
981
|
+
const tagCfg = tagsMod.loadTags(p);
|
|
982
|
+
const plan = tagsMod.planStatus(tagCfg);
|
|
983
|
+
if (plan.exists && !plan.ok) {
|
|
984
|
+
if (plan.placeholders.length) return fail(`the tag plan isn't finished — relabel the placeholder tag(s) ${plan.placeholders.map((t) => `"${t}"`).join(', ')} first (\`yay tags rename "Custom 1" "<Real name>"\` or the dashboard Tags tab). Briefs are never tagged with placeholders.`);
|
|
985
|
+
return fail(`the tag plan needs at least ${tagsMod.MIN_PLAN_TAGS} unique tags (it has ${plan.unique}) — add more with \`yay tags add "<Tag>"\`, or switch sets with \`yay tags --set <id>\`.`);
|
|
986
|
+
}
|
|
987
|
+
if (!plan.exists && Object.keys(items).some((id) => /DERIVED — unconfirmed/.test(manifest.cells[id].specBlock || ''))) {
|
|
988
|
+
return fail(`adopted Cells can't be signed before a tag plan exists — pick one first (\`yay tags --set <id>\`, ≥${tagsMod.MIN_PLAN_TAGS} unique tags), then sign the adoption as per-concern Briefs.`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const n = (lock.approvals || []).length + 1;
|
|
992
|
+
const approval = {
|
|
993
|
+
id: 'A-' + String(n).padStart(4, '0'),
|
|
994
|
+
project: config.project,
|
|
995
|
+
prev: n > 1 ? lock.approvals[lock.approvals.length - 1].id : 'genesis',
|
|
996
|
+
nonce: C.randomNonce(),
|
|
997
|
+
at: new Date().toISOString(),
|
|
998
|
+
signer: name,
|
|
999
|
+
items,
|
|
1000
|
+
};
|
|
1001
|
+
// D3: tie the human signature to the exact MACHINE VERDICT it was made against, when a signed
|
|
1002
|
+
// verifier attestation covers this exact code-tree. Recorded inside the seal (so it's part of what
|
|
1003
|
+
// the human signs), not asserted — absent when no current attestation exists (e.g. a forward
|
|
1004
|
+
// spec-sign before the code was attested). "I approve this intent AND this is the verified result
|
|
1005
|
+
// I saw."
|
|
1006
|
+
{
|
|
1007
|
+
const last = A.latestEntry(p, config);
|
|
1008
|
+
if (last && last.codeTreeHash === A.codeTreeHashOf(manifest)) {
|
|
1009
|
+
approval.attest = { hash: last.hash, capability: last.capability };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// BRIEF (Standard §5): a short prose record of what the human ordered, signed
|
|
1013
|
+
// together with the specs → attributed + tamper-evident. It is the DEFAULT: a human
|
|
1014
|
+
// at a TTY is prompted for it; automation (the AI) passes --brief; --no-brief is
|
|
1015
|
+
// the explicit escape for a trivial re-sign. Kept in the approval so canonical(approval)
|
|
1016
|
+
// covers it on every signing path.
|
|
1017
|
+
let briefText = (flags.brief && flags.brief !== true) ? String(flags.brief).trim()
|
|
1018
|
+
: (flags['brief-file'] && flags['brief-file'] !== true && fs.existsSync(flags['brief-file'])) ? fs.readFileSync(flags['brief-file'], 'utf8').trim()
|
|
1019
|
+
: '';
|
|
1020
|
+
if (!briefText && !flags['no-brief']) {
|
|
1021
|
+
if (process.stdin.isTTY) {
|
|
1022
|
+
console.log(U.c.accent('▸ ') + U.c.bold('Brief') + U.c.dim(' — in one line, what are you approving here (what you ordered)?'));
|
|
1023
|
+
briefText = await ask(' brief: ');
|
|
1024
|
+
}
|
|
1025
|
+
if (!briefText) return fail('a Brief is required (Standard §5) — pass --brief "<what you ordered>", or --no-brief for a trivial re-sign.');
|
|
1026
|
+
}
|
|
1027
|
+
// A short TITLE (headline) over the Brief prose — like a commit subject over its body —
|
|
1028
|
+
// so the ledger, clouds and phone card are scannable. Signed with the Brief (tamper-evident).
|
|
1029
|
+
let titleText = (flags.title && flags.title !== true) ? String(flags.title).trim() : '';
|
|
1030
|
+
if (briefText && !titleText && !flags['no-title'] && process.stdin.isTTY) {
|
|
1031
|
+
titleText = (await ask(' title (short headline, optional): ')).trim();
|
|
1032
|
+
}
|
|
1033
|
+
if (briefText) approval.brief = Object.assign({ text: briefText, orderedBy: 'human (AI-drafted, human-approved)' }, titleText ? { title: titleText } : {});
|
|
1034
|
+
|
|
1035
|
+
// ── Brief tags (Standard §5) ── if the project defines a pool, every Brief is tagged from
|
|
1036
|
+
// it. Tags ride inside the (signed) brief, so they're attributed + tamper-evident.
|
|
1037
|
+
if (approval.brief) {
|
|
1038
|
+
const tagCfg = tagsMod.loadTags(p);
|
|
1039
|
+
if (tagCfg) {
|
|
1040
|
+
let tags = tagsMod.parseTags(tagCfg.tags, (flags.tags && flags.tags !== true) ? String(flags.tags) : '');
|
|
1041
|
+
if (!tags.length && !flags['no-tags']) {
|
|
1042
|
+
if (process.stdin.isTTY) {
|
|
1043
|
+
console.log(U.c.accent('▸ ') + U.c.bold('Tags') + U.c.dim(' — comma-separated (1–3 ideal), from: ') + U.c.dim(tagCfg.tags.join(', ')));
|
|
1044
|
+
tags = tagsMod.parseTags(tagCfg.tags, await ask(' tags: '));
|
|
1045
|
+
}
|
|
1046
|
+
if (!tags.length) return fail(`this project tags every Brief — pass --tags "Tag1,Tag2" from: ${tagCfg.tags.join(', ')} (or --no-tags to skip).`);
|
|
1047
|
+
}
|
|
1048
|
+
if (tags.length) {
|
|
1049
|
+
const unknown = tagsMod.unknownTags(tagCfg.tags, tags);
|
|
1050
|
+
if (unknown.length) console.log(U.c.yellow(` ⚠ not in the tag pool: ${unknown.join(', ')}`) + U.c.dim(' — add with `yay tags add`, or pick from the pool.'));
|
|
1051
|
+
if (tags.length > 4) console.log(U.c.yellow(` ⚠ ${tags.length} tags on one Brief`) + U.c.dim(' — mixing concerns? Consider splitting into separate Briefs (1–3 tags each).'));
|
|
1052
|
+
approval.brief.tags = tags;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ── Cross-signer routing ── when --name targets someone OTHER than this machine's own
|
|
1058
|
+
// signer (over the relay), seal the request to THEIR inbox and return. Nothing pops on
|
|
1059
|
+
// this machine's phone; only the addressed person's on-duty phone sees it.
|
|
1060
|
+
const localSigner = loadLocalSigner(p);
|
|
1061
|
+
const isCross = localSigner && (flags.name && flags.name !== true) && name !== localSigner
|
|
1062
|
+
&& !flags.local && phoneTransport(config, flags) === 'relay';
|
|
1063
|
+
if (isCross) return sendToInbox(p, config, lock, manifest, items, approval, name, flags);
|
|
1064
|
+
|
|
1065
|
+
// ── Autopilot ── if an active grant covers ALL target Cells (none sensitive), the
|
|
1066
|
+
// machine approves with the grant key (delegated) — no phone, no passphrase. Queued for ratify.
|
|
1067
|
+
if (!flags['no-auto']) {
|
|
1068
|
+
const glog = loadGrants(p);
|
|
1069
|
+
if (glog && glog.events && glog.events.length) {
|
|
1070
|
+
const rlog = loadRoster(p);
|
|
1071
|
+
const drv = rosterMod.deriveRoster(rlog || { events: [] });
|
|
1072
|
+
const ownerPubs = Object.keys(drv.roles || {}).filter((n) => drv.roles[n] === 'owner').reduce((a, n) => a.concat(drv.roster[n] || []), []);
|
|
1073
|
+
const grants = grantsMod.deriveGrants(glog, ownerPubs, lock.approvals);
|
|
1074
|
+
const cellsById = {}; Object.keys(items).forEach((id) => { if (manifest.cells[id]) cellsById[id] = manifest.cells[id]; });
|
|
1075
|
+
// Owner-signed policy = the authoritative non-delegable backstop (outside the AI-writable surface).
|
|
1076
|
+
const enforcedPolicy = (drv.policy && drv.policy.rules) ? drv.policy : { rules: [] };
|
|
1077
|
+
const g = grantsMod.activeGrantFor(grants, Object.keys(items), cellsById, { policy: enforcedPolicy });
|
|
1078
|
+
if (g) {
|
|
1079
|
+
const rec = U.readJSON(grantKeyPath(p, g.id), null);
|
|
1080
|
+
if (rec && rec.priv) {
|
|
1081
|
+
approval.autoApproved = true; approval.grant = g.id; approval.signer = g.by || config.owners[0] || 'owner';
|
|
1082
|
+
approval.signature = C.sign(U.canonical(approval), Buffer.from(rec.priv, 'base64'));
|
|
1083
|
+
lock.approvals = lock.approvals || []; lock.approvals.push(approval); U.writeJSON(p.lock, lock);
|
|
1084
|
+
Object.keys(items).forEach((id) => { if (manifest.cells[id]) stampAutoCell(p, manifest.cells[id], g.id); }); // in-code AUTO stamp
|
|
1085
|
+
console.log(U.c.yellow(`⚡ delegated ${Object.keys(items).length} Cell(s)`) + U.c.dim(` under grant ${g.id} — approval ${approval.id} (Autopilot).`));
|
|
1086
|
+
if (approval.brief) console.log(' ' + U.c.dim('brief: ') + approval.brief.text);
|
|
1087
|
+
const left = g.remaining != null ? Math.max(0, g.remaining - 1) : '∞';
|
|
1088
|
+
console.log(' ' + U.c.dim(`delegated, NOT human-reviewed — ratify later with `) + U.c.bold('yay ratify') + U.c.dim(`. ${left} delegation(s) left · grant expires ${String(g.expiresAt).slice(0, 16).replace('T', ' ')}.`));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
console.log(U.c.yellow(` grant ${g.id} is active but its key is missing on this machine — falling back to a normal signature.`));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const method = resolveSignMethod(config, p, name, flags);
|
|
1097
|
+
if (method === 'phone') {
|
|
1098
|
+
// Sign on the paired phone over the LAN — the private key never touches this machine.
|
|
1099
|
+
const summary = signSummary(p, config, lock, manifest, items);
|
|
1100
|
+
const pubs = U.pubKeysOf(config.signers[name]);
|
|
1101
|
+
const tagPool = (tagsMod.loadTags(p) || { tags: [] }).tags; // so the phone can offer in-pool tag corrections
|
|
1102
|
+
if (phoneTransport(config, flags) === 'relay') {
|
|
1103
|
+
// Hosted relay (relay.yaylayer.com), end-to-end encrypted. Works off-LAN.
|
|
1104
|
+
const routed = await routeThroughRelay(p, 'approve', { approval, summary, tagPool, expectPubB64: pubs, signer: name, signerPubs: pubs }, flags);
|
|
1105
|
+
if (!routed || !routed.result) return; // routeThroughRelay logged why
|
|
1106
|
+
if (reportSendBack(routed.result, name)) { await relayFinal(routed.sess, { ok: false, reason: 'Sent back for changes.' }); return; }
|
|
1107
|
+
if (routed.result.brief !== undefined && approval.brief) approval.brief.text = routed.result.brief; // legacy: older phone edited it
|
|
1108
|
+
approval.signature = routed.result.signature;
|
|
1109
|
+
await relayFinal(routed.sess, { ok: true, message: 'Signed ✓ — leave the page open for the next request.' });
|
|
1110
|
+
} else {
|
|
1111
|
+
// If a dashboard is running, route through it — the request pops up on the phone
|
|
1112
|
+
// the human already has open (scan-once). Otherwise spin the one-shot LAN server.
|
|
1113
|
+
const routed = await routeThroughDashboard(p, 'approve', { approval, summary, tagPool, expectPubB64: pubs, signer: name, signerPubs: pubs });
|
|
1114
|
+
if (routed && routed.busy) return;
|
|
1115
|
+
if (routed && routed.result) {
|
|
1116
|
+
if (reportSendBack(routed.result, name)) { await dashboardFinal(routed.info, { ok: false, reason: 'Sent back for changes.' }); return; }
|
|
1117
|
+
if (routed.result.brief !== undefined && approval.brief) approval.brief.text = routed.result.brief; // legacy: older phone edited it
|
|
1118
|
+
approval.signature = routed.result.signature;
|
|
1119
|
+
await dashboardFinal(routed.info, { ok: true, message: 'Signed ✓ — you can leave this open for the next request.' });
|
|
1120
|
+
} else {
|
|
1121
|
+
const tls = tlsCert(p, flags);
|
|
1122
|
+
const s = await phone.signOverLan({ project: config.project, approval, summary, tagPool, expectPubB64: pubs, tls });
|
|
1123
|
+
console.log('\n' + U.c.bold('Approve on your phone') + ' — scan with your phone camera (same Wi-Fi):');
|
|
1124
|
+
console.log(' ' + U.c.accent(s.url) + U.c.dim(' (or ' + s.local + ' on this computer)'));
|
|
1125
|
+
printQR(s.url);
|
|
1126
|
+
if (s.fellBack) console.log(U.c.yellow(' ⚠ the preferred phone port was busy (another yay sign/serve running?) — using a different address; your phone may ask to Restore.'));
|
|
1127
|
+
if (tls) console.log(U.c.dim(' https: tap through the one-time "not private" warning (Advanced → visit).'));
|
|
1128
|
+
console.log(U.c.dim(` reviewing ${summary.length} change(s) as "${name}" · Ctrl-C to cancel · tip: run \`yay dashboard\` to scan once and skip the QR each time`));
|
|
1129
|
+
let r; try { r = await s.done; } finally { s.close(); }
|
|
1130
|
+
if (r && r.timedOut) return fail('no approval received in time — nothing was signed. Re-run when ready.');
|
|
1131
|
+
if (reportSendBack(r, name)) return;
|
|
1132
|
+
if (r && r.brief !== undefined && approval.brief) approval.brief.text = r.brief; // legacy: older phone edited it
|
|
1133
|
+
approval.signature = r.signature;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} else {
|
|
1137
|
+
// Local key: show the same review the phone shows (spec + diff + Brief + state), then confirm,
|
|
1138
|
+
// BEFORE unlocking the key. --yes / --no-review or a non-TTY (scripts, CI, ratify) skip the prompt.
|
|
1139
|
+
if (process.stdin.isTTY && !flags.yes && !flags.y && !flags['no-review']) {
|
|
1140
|
+
printSignReview(signSummary(p, config, lock, manifest, items), name, approval.brief);
|
|
1141
|
+
const ans = (await ask(`\n Sign these ${Object.keys(items).length} Cell(s) as "${name}"? (y/N): `)).trim();
|
|
1142
|
+
if (!/^y/i.test(ans)) return fail('not signed — nothing written.');
|
|
1143
|
+
}
|
|
1144
|
+
const ksPath = path.join(p.keys, `${name}.keystore`);
|
|
1145
|
+
if (!fs.existsSync(ksPath)) return fail(`no keystore for "${name}" — if this signer is a phone, use \`yay sign --phone\``);
|
|
1146
|
+
const pass = await getPassphrase(flags, `Enter ${name}'s passphrase to sign`);
|
|
1147
|
+
let privDer;
|
|
1148
|
+
try { privDer = C.decryptKeystore(JSON.parse(fs.readFileSync(ksPath, 'utf8')), pass); }
|
|
1149
|
+
catch (e) { return fail(e.message); }
|
|
1150
|
+
approval.signature = C.sign(U.canonical(approval), privDer);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
lock.approvals = lock.approvals || [];
|
|
1154
|
+
lock.approvals.push(approval);
|
|
1155
|
+
U.writeJSON(p.lock, lock);
|
|
1156
|
+
Object.keys(items).forEach((id) => { if (manifest.cells[id]) unstampAutoCell(p, manifest.cells[id]); }); // human sign clears any AUTO stamp (ratified)
|
|
1157
|
+
console.log(U.c.green(`✓ signed ${Object.keys(items).length} Cell(s)`) + ` as "${name}" — approval ${approval.id}`);
|
|
1158
|
+
if (approval.brief) console.log(' ' + U.c.bold('brief: ') + U.c.accent(approval.brief.text));
|
|
1159
|
+
console.log(' ' + U.c.dim('seal appended to .yaylayer/lock.json (commit this)'));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Shared pairing flow (used by `yay pair` and by `yay init` when Mobile is chosen).
|
|
1163
|
+
async function runPairing(p, config, flags, genesisMeta) {
|
|
1164
|
+
flags = flags || {};
|
|
1165
|
+
const nameFlag = (flags.name && flags.name !== true) ? flags.name : null;
|
|
1166
|
+
const tls = tlsCert(p, flags);
|
|
1167
|
+
// No signed trust root yet? Run PHONE-AS-GENESIS: the phone self-signs the
|
|
1168
|
+
// genesis event and becomes the owner/root — no local key is ever created.
|
|
1169
|
+
// genesisMeta (set on a reroot) stamps the rotation into the signed genesis.
|
|
1170
|
+
const rlogPre = loadRoster(p);
|
|
1171
|
+
const isGenesis = !(rlogPre && rlogPre.events && rlogPre.events.length);
|
|
1172
|
+
const genesis = isGenesis
|
|
1173
|
+
? { id: 'R-0001', type: 'genesis', role: 'owner', prev: 'genesis', nonce: C.randomNonce(), at: new Date().toISOString(), ...(genesisMeta || {}) }
|
|
1174
|
+
: null;
|
|
1175
|
+
const challenge = C.randomNonce() + C.randomNonce();
|
|
1176
|
+
let r, finishPhone, closeServer = () => {};
|
|
1177
|
+
if (phoneTransport(config, flags) === 'relay') {
|
|
1178
|
+
// Hosted relay (relay.yaylayer.com), end-to-end encrypted.
|
|
1179
|
+
const routed = await routeThroughRelay(p, 'pair', { challenge, genesis }, flags);
|
|
1180
|
+
if (!routed || !routed.result) return false;
|
|
1181
|
+
r = routed.result; finishPhone = (f) => relayFinal(routed.sess, f);
|
|
1182
|
+
} else {
|
|
1183
|
+
// Route through a running dashboard (one origin, scan-once) if present; else the one-shot LAN server.
|
|
1184
|
+
const routed = await routeThroughDashboard(p, 'pair', { challenge, genesis });
|
|
1185
|
+
if (routed && routed.busy) return false;
|
|
1186
|
+
if (routed && routed.result) {
|
|
1187
|
+
r = routed.result;
|
|
1188
|
+
finishPhone = (f) => dashboardFinal(routed.info, f);
|
|
1189
|
+
} else {
|
|
1190
|
+
const s = await phone.pairOverLan({ project: config.project, tls, genesis, challenge });
|
|
1191
|
+
console.log('\n' + U.c.bold('Pair your phone') + ' — scan with your phone camera (same Wi-Fi):');
|
|
1192
|
+
console.log(' ' + U.c.accent(s.url) + U.c.dim(' (or ' + s.local + ' on this computer)'));
|
|
1193
|
+
printQR(s.url);
|
|
1194
|
+
if (s.fellBack) console.log(U.c.yellow(' ⚠ the preferred phone port was busy (another yay sign/serve running?) — using a different address; the phone may ask to Restore.'));
|
|
1195
|
+
if (tls) console.log(U.c.dim(' https: tap through the one-time "not private" warning (Advanced → visit).'));
|
|
1196
|
+
console.log(U.c.dim(' create your key there; it shows a 6-digit code. (Ctrl-C to cancel.)'));
|
|
1197
|
+
finishPhone = async (finalMsg) => { s.setFinal(finalMsg); await Promise.race([s.settled, new Promise((res) => setTimeout(res, 8000))]); };
|
|
1198
|
+
closeServer = () => s.close();
|
|
1199
|
+
r = await s.done;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
if (r && r.timedOut) { console.log(U.c.red(' pairing timed out — no phone responded.')); return false; }
|
|
1204
|
+
const name = nameFlag || r.name;
|
|
1205
|
+
console.log('\n Your phone should show code: ' + U.c.bold(r.code));
|
|
1206
|
+
const ans = await ask(' Does it match exactly? (y/N): ');
|
|
1207
|
+
if (!/^y/i.test(ans)) {
|
|
1208
|
+
console.log(U.c.red(' pairing aborted — code did not match (possible wrong device)'));
|
|
1209
|
+
await finishPhone({ ok: false, reason: 'the code did not match on the laptop' });
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
// Remember who this machine signs as, so `yay sign` defaults to them (not the owner).
|
|
1213
|
+
saveLocalSigner(p, name);
|
|
1214
|
+
|
|
1215
|
+
if (!isGenesis) {
|
|
1216
|
+
const rlog = loadRoster(p);
|
|
1217
|
+
// A signed trust root exists → the phone key must be authorized by an existing
|
|
1218
|
+
// OWNER (signed roster event), or the gate won't trust it.
|
|
1219
|
+
const drv = rosterMod.deriveRoster(rlog);
|
|
1220
|
+
if ((drv.roster[name] || []).includes(r.pubB64)) {
|
|
1221
|
+
console.log(U.c.dim(` this phone key is already enrolled for "${name}".`));
|
|
1222
|
+
await finishPhone({ ok: true, message: 'Already paired — you can close this.' });
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
const owners = Object.keys(drv.roles).filter((n) => drv.roles[n] === 'owner');
|
|
1226
|
+
const byFlag = (flags.by && flags.by !== true) ? flags.by : null;
|
|
1227
|
+
const authOwner = (byFlag && fs.existsSync(path.join(p.keys, `${byFlag}.keystore`))) ? byFlag
|
|
1228
|
+
: owners.find((n) => fs.existsSync(path.join(p.keys, `${n}.keystore`)));
|
|
1229
|
+
if (!authOwner) {
|
|
1230
|
+
addSignerKey(config, name, r.pubB64, 'phone'); U.writeJSON(p.config, config);
|
|
1231
|
+
console.log(U.c.yellow(' ⚠ phone captured, but NOT enrolled in the signed roster') + U.c.dim(' — no owner key on this machine to authorize it.'));
|
|
1232
|
+
console.log(' ' + U.c.dim('From a machine holding an owner key, run: ') + U.c.bold(`yay enroll --name "${name}" --pubkey ${r.pubB64} --phone`));
|
|
1233
|
+
await finishPhone({ ok: false, reason: 'no owner on the laptop authorized this phone yet — run yay enroll' });
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
const pass = await getPassphrase(flags, `Enter ${authOwner}'s passphrase to authorize adding your phone`);
|
|
1237
|
+
let priv;
|
|
1238
|
+
try { priv = C.decryptKeystore(JSON.parse(fs.readFileSync(path.join(p.keys, `${authOwner}.keystore`), 'utf8')), pass); }
|
|
1239
|
+
catch (e) { console.log(U.c.red(' ' + e.message)); await finishPhone({ ok: false, reason: 'the laptop could not unlock the owner key' }); return false; }
|
|
1240
|
+
const type = drv.roster[name] ? 'add-key' : 'add-signer';
|
|
1241
|
+
const role = drv.roles[name] || (flags.role === 'owner' ? 'owner' : 'signer');
|
|
1242
|
+
const ev = { id: rosterMod.nextEventId(rlog), type, name, pub: r.pubB64, role, by: authOwner, prev: rlog.events[rlog.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1243
|
+
ev.signature = C.sign(rosterMod.eventBytes(ev), priv);
|
|
1244
|
+
const test = rosterMod.deriveRoster({ ...rlog, events: rlog.events.concat([ev]) });
|
|
1245
|
+
if (test.problems.length) { console.log(U.c.red(' refusing to write — ' + test.problems.join('; '))); await finishPhone({ ok: false, reason: 'the roster event did not validate' }); return false; }
|
|
1246
|
+
rlog.events.push(ev);
|
|
1247
|
+
U.writeJSON(rosterPath(p), rlog);
|
|
1248
|
+
addSignerKey(config, name, r.pubB64, 'phone'); U.writeJSON(p.config, config);
|
|
1249
|
+
console.log(U.c.green(`✓ paired "${name}"`) + U.c.dim(` — phone key added to the SIGNED roster (authorized by ${authOwner}). Commit .yaylayer/. Sign with `) + U.c.bold('yay sign --phone') + U.c.dim('.'));
|
|
1250
|
+
await finishPhone({ ok: true, message: 'Paired — you can close this. The laptop has your key.' });
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Phone-as-genesis: the phone self-signed the genesis event. Its signature is
|
|
1255
|
+
// the trust root; the private key never leaves the phone, and no local key exists.
|
|
1256
|
+
const g = r.genesisEvent;
|
|
1257
|
+
if (!g || g.pub !== r.pubB64) { console.log(U.c.red(' pairing failed — no valid genesis signature from the phone.')); await finishPhone({ ok: false, reason: 'no valid genesis signature from the phone' }); return false; }
|
|
1258
|
+
const check = rosterMod.deriveRoster({ project: config.project, events: [g] });
|
|
1259
|
+
if (!check.ok) { console.log(U.c.red(' refusing to establish trust root — ' + check.problems.join('; '))); await finishPhone({ ok: false, reason: 'the genesis event did not validate' }); return false; }
|
|
1260
|
+
U.writeJSON(rosterPath(p), { project: config.project, events: [g] });
|
|
1261
|
+
addSignerKey(config, name, r.pubB64, 'phone');
|
|
1262
|
+
config.devices = config.devices || {}; config.devices[name] = config.devices[name] || {}; config.devices[name].phonePairedAt = new Date().toISOString();
|
|
1263
|
+
U.writeJSON(p.config, config);
|
|
1264
|
+
console.log('\n' + U.c.green(`✓ trust root established on your phone for "${name}"`) + U.c.dim(' — no local key needed.'));
|
|
1265
|
+
console.log(' ' + U.c.dim('root fingerprint → ') + U.c.bold(rosterMod.fingerprint(r.pubB64)) + U.c.dim(' (pin this in CI)'));
|
|
1266
|
+
console.log(' ' + U.c.dim('commit ') + U.c.bold('.yaylayer/') + U.c.dim(', then approve change-sets with ') + U.c.bold('yay sign --phone'));
|
|
1267
|
+
await finishPhone({ ok: true, message: 'You’re the trust root now — you can close this.' });
|
|
1268
|
+
return true;
|
|
1269
|
+
} finally { closeServer(); }
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Get an OWNER to sign a governance event `ev` (enroll / revoke / reroot).
|
|
1273
|
+
// Uses a local owner keystore when present, else authorizes on an owner's PHONE
|
|
1274
|
+
// (so a phone-only owner can manage the roster with no key on this machine).
|
|
1275
|
+
// Returns the signed `ev`, or null if it couldn't be authorized. `ev.by` is set
|
|
1276
|
+
// before signing so it's covered by the signature.
|
|
1277
|
+
async function authorizeRosterEvent(p, config, log, ev, flags, summary) {
|
|
1278
|
+
const drv = rosterMod.deriveRoster(log);
|
|
1279
|
+
const owners = Object.keys(drv.roles).filter((n) => drv.roles[n] === 'owner');
|
|
1280
|
+
if (!owners.length) { console.log(U.c.red(' no owner in the roster to authorize with.')); return null; }
|
|
1281
|
+
const byFlag = (flags.by && flags.by !== true) ? flags.by : null;
|
|
1282
|
+
if (byFlag && drv.roles[byFlag] !== 'owner') { console.log(U.c.red(` "${byFlag}" is not an owner.`)); return null; }
|
|
1283
|
+
const wantPhone = !!flags.phone;
|
|
1284
|
+
const localOwner = wantPhone ? null
|
|
1285
|
+
: ((byFlag && fs.existsSync(path.join(p.keys, `${byFlag}.keystore`))) ? byFlag
|
|
1286
|
+
: owners.find((n) => fs.existsSync(path.join(p.keys, `${n}.keystore`))));
|
|
1287
|
+
if (localOwner) {
|
|
1288
|
+
ev.by = localOwner;
|
|
1289
|
+
const pass = await getPassphrase(flags, `Enter ${localOwner}'s passphrase to authorize`);
|
|
1290
|
+
try { ev.signature = C.sign(rosterMod.eventBytes(ev), C.decryptKeystore(JSON.parse(fs.readFileSync(path.join(p.keys, `${localOwner}.keystore`), 'utf8')), pass)); }
|
|
1291
|
+
catch (e) { console.log(U.c.red(' ' + e.message)); return null; }
|
|
1292
|
+
return ev;
|
|
1293
|
+
}
|
|
1294
|
+
// Phone authorization: any current owner's phone can sign the event bytes.
|
|
1295
|
+
ev.by = byFlag || owners[0];
|
|
1296
|
+
const ownerPubs = owners.reduce((a, n) => a.concat(drv.roster[n] || []), []);
|
|
1297
|
+
if (phoneTransport(config, flags) === 'relay') {
|
|
1298
|
+
const routed = await routeThroughRelay(p, 'authorize', { event: ev, summary, ownerPubs, signer: 'an owner', signerPubs: ownerPubs }, flags);
|
|
1299
|
+
if (!routed || !routed.result) return null;
|
|
1300
|
+
ev.signature = routed.result.signature; await relayFinal(routed.sess, { ok: true, message: 'Authorized ✓' }); return ev;
|
|
1301
|
+
}
|
|
1302
|
+
// Route through a running dashboard (one origin) if there is one; else ephemeral.
|
|
1303
|
+
const routed = await routeThroughDashboard(p, 'authorize', { event: ev, summary, ownerPubs, signer: 'an owner', signerPubs: ownerPubs });
|
|
1304
|
+
if (routed && routed.busy) return null;
|
|
1305
|
+
if (routed && routed.result) { ev.signature = routed.result.signature; await dashboardFinal(routed.info, { ok: true, message: 'Authorized ✓ — you can leave this open.' }); return ev; }
|
|
1306
|
+
const tls = tlsCert(p, flags);
|
|
1307
|
+
const s = await phone.authorizeOverLan({ project: config.project, event: ev, summary, ownerPubs, tls });
|
|
1308
|
+
console.log('\n' + U.c.bold('Authorize on an owner’s phone') + ' — scan (same Wi-Fi):');
|
|
1309
|
+
console.log(' ' + U.c.accent(s.url) + U.c.dim(' (or ' + s.local + ' on this computer)'));
|
|
1310
|
+
printQR(s.url);
|
|
1311
|
+
if (s.fellBack) console.log(U.c.yellow(' ⚠ the preferred phone port was busy — using a different address; the phone may ask to Restore.'));
|
|
1312
|
+
if (tls) console.log(U.c.dim(' https: tap through the one-time "not private" warning.'));
|
|
1313
|
+
console.log(U.c.dim(' review the change on the phone and approve. (Ctrl-C to cancel.)'));
|
|
1314
|
+
let r; try { r = await s.done; } finally { s.close(); }
|
|
1315
|
+
if (r && r.timedOut) { console.log(U.c.red(' authorization timed out — no phone responded.')); return null; }
|
|
1316
|
+
ev.signature = r.signature;
|
|
1317
|
+
return ev;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ── Foundation seal ───────────────────────────────────────────────────────
|
|
1321
|
+
// The git-tracked file set — the seal's structural watch keys off this, so gitignored
|
|
1322
|
+
// files (.env…) are naturally out. Returns null when git is unavailable (the seal needs git).
|
|
1323
|
+
function trackedFiles(root) {
|
|
1324
|
+
try {
|
|
1325
|
+
const out = require('child_process').execSync('git ls-files', { cwd: root, encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 });
|
|
1326
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1327
|
+
} catch (_) { return null; }
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// `yay protect` — an owner signs a baseline of the fixed core files, so any later change is
|
|
1331
|
+
// REVEALED at verify (src/foundation.js). Stored as an owner-signed, trust-root-pinned roster
|
|
1332
|
+
// event (un-removable); the posture also lives in committed config so it survives a reroot and
|
|
1333
|
+
// tells verify a seal is EXPECTED.
|
|
1334
|
+
async function cmdProtect(flags, positional) {
|
|
1335
|
+
const { p, config } = loadState();
|
|
1336
|
+
if (!config) return fail('run `yay init` first');
|
|
1337
|
+
const rlog = loadRoster(p);
|
|
1338
|
+
if (!rlog || !rlog.events || !rlog.events.length) return fail('no trust root yet — run `yay init` (the seal is owner-signed).');
|
|
1339
|
+
const drv = rosterMod.deriveRoster(rlog);
|
|
1340
|
+
const prevSeal = drv.foundation || {};
|
|
1341
|
+
const off = !!flags.off || positional[0] === 'off';
|
|
1342
|
+
const mode = off ? 'off'
|
|
1343
|
+
: (flags.mode && flags.mode !== true ? String(flags.mode).toLowerCase()
|
|
1344
|
+
: (drv.foundationMode !== 'off' ? drv.foundationMode : 'guarded'));
|
|
1345
|
+
if (!['off', 'guarded', 'strict'].includes(mode)) return fail('--mode must be guarded or strict (or pass --off).');
|
|
1346
|
+
const list = (v) => (v && v !== true) ? String(v).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
1347
|
+
let seal = null;
|
|
1348
|
+
if (mode !== 'off') {
|
|
1349
|
+
const tracked = trackedFiles(p.root);
|
|
1350
|
+
if (tracked == null) return fail('the foundation seal needs git (it watches the tracked-file set) — run `git init` and commit first.');
|
|
1351
|
+
// Carry prior customizations forward; --ignore/--add extend, --unignore/--remove drop.
|
|
1352
|
+
const ignore = [...new Set([...(prevSeal.ignore || []), ...list(flags.ignore)])].filter((g) => !list(flags.unignore).includes(g));
|
|
1353
|
+
const extra = [...new Set([...(prevSeal.extra || []), ...list(flags.add)])].filter((g) => !list(flags.remove).includes(g));
|
|
1354
|
+
seal = F.buildSeal(p.root, tracked, { ignore, extra });
|
|
1355
|
+
}
|
|
1356
|
+
const ev = { id: rosterMod.nextEventId(rlog), type: 'foundation', mode, seal, prev: rlog.events[rlog.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1357
|
+
const nFiles = seal ? Object.keys(seal.files).length : 0;
|
|
1358
|
+
const summary = off
|
|
1359
|
+
? { title: 'Disable the foundation seal', rows: [{ k: 'action', v: 'retire foundation protection' }], warn: 'The fixed-core files will no longer be watched for tampering or corruption. Re-enable any time with `yay protect`.' }
|
|
1360
|
+
: { title: 'Seal the project foundation', rows: [{ k: 'mode', v: mode + (mode === 'strict' ? ' — drift blocks the gate' : ' — drift warns') }, { k: 'files', v: nFiles + ' core file(s) hashed' }, { k: 'zones', v: Object.keys(seal.zones).join(', ') }], warn: 'You are vouching that the current fixed-core files are correct. Any later change to them is revealed at verify until you re-seal.' };
|
|
1361
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
1362
|
+
if (!signed) return;
|
|
1363
|
+
rlog.events.push(signed); U.writeJSON(rosterPath(p), rlog);
|
|
1364
|
+
config.foundation = mode; U.writeJSON(p.config, config); // posture survives reroot; the "expected" flag
|
|
1365
|
+
if (off) { console.log('\n' + U.c.yellow('• foundation seal disabled') + U.c.dim(' — core files are no longer watched. Commit .yaylayer/roster.json + config.json.')); return; }
|
|
1366
|
+
console.log('\n' + U.c.green(`✓ foundation sealed (${mode})`) + U.c.dim(` — ${nFiles} core file(s) + zones ${Object.keys(seal.zones).join(', ')}. Commit .yaylayer/roster.json + config.json.`));
|
|
1367
|
+
console.log(' ' + U.c.dim('any later change to a sealed file (or a new/removed file in a watched zone) is revealed by ') + U.c.bold('yay verify') + U.c.dim('. Re-seal after a legitimate change.'));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// ── Autopilot commands ──────────────────────────────────────────────────
|
|
1371
|
+
function listGrants(p, config, lock) {
|
|
1372
|
+
const glog = loadGrants(p);
|
|
1373
|
+
if (!glog || !glog.events.filter((e) => e.type === 'grant').length) { console.log(U.c.dim('no grants issued — start Autopilot with `yay grant --for 2h --count 20`.')); return; }
|
|
1374
|
+
const drv = rosterMod.deriveRoster(loadRoster(p) || { events: [] });
|
|
1375
|
+
const ownerPubs = Object.keys(drv.roles || {}).filter((n) => drv.roles[n] === 'owner').reduce((a, n) => a.concat(drv.roster[n] || []), []);
|
|
1376
|
+
const grants = grantsMod.deriveGrants(glog, ownerPubs, (lock && lock.approvals) || []);
|
|
1377
|
+
console.log(U.c.bold('Grants (Autopilot):'));
|
|
1378
|
+
for (const id of Object.keys(grants)) {
|
|
1379
|
+
const g = grants[id];
|
|
1380
|
+
const status = g.active ? U.c.green('● active') : g.revoked ? U.c.red('revoked') : g.expired ? U.c.dim('expired ') : U.c.dim('spent ');
|
|
1381
|
+
const env = grantsMod.envelopeOf(g);
|
|
1382
|
+
const scopeBits = [];
|
|
1383
|
+
if (env.cells.length) scopeBits.push(env.cells.length + ' Cell(s)');
|
|
1384
|
+
if (env.allow.length) scopeBits.push('allow ' + env.allow.join(','));
|
|
1385
|
+
if (env.deny.length) scopeBits.push('deny ' + env.deny.join(','));
|
|
1386
|
+
if (env.maxRisk) scopeBits.push('≤' + env.maxRisk + ' risk');
|
|
1387
|
+
const scopeStr = scopeBits.length ? scopeBits.join(' · ') : 'non-sensitive';
|
|
1388
|
+
const indent = g.parent ? ' └─ ' : ' ';
|
|
1389
|
+
const parentTag = g.parent ? U.c.dim(`child of ${g.parent} `) + (g.chain && !g.chain.attenuates ? U.c.red('⚠ ' + (g.chain.reason || 'bad chain') + ' ') : '') : '';
|
|
1390
|
+
console.log(indent + status + ' ' + U.c.bold(id) + ' ' + parentTag + U.c.dim(`· ${scopeStr} · ${g.spent}/${g.maxCount || '∞'} used · expires ${String(g.expiresAt).slice(0, 16).replace('T', ' ')}`) + (env.childGrants.allowed ? U.c.dim(` · child-grants✓(d${env.childGrants.maxDepth})`) : ''));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
async function grantRevoke(p, config, rlog, flags, positional) {
|
|
1394
|
+
if (!rlog) return fail('no signed roster — nothing to revoke against.');
|
|
1395
|
+
const glog = loadGrants(p);
|
|
1396
|
+
const gEvents = (glog && glog.events || []).filter((e) => e.type === 'grant');
|
|
1397
|
+
if (!gEvents.length) return fail('no grants to revoke.');
|
|
1398
|
+
const target = positional[1] || ((flags.grant && flags.grant !== true) ? flags.grant : gEvents[gEvents.length - 1].id);
|
|
1399
|
+
const prev = glog.events[glog.events.length - 1].id;
|
|
1400
|
+
const ev = { id: 'GR-' + String(glog.events.length + 1).padStart(3, '0'), type: 'grant-revoke', grant: target, prev, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1401
|
+
const summary = { title: `Revoke grant ${target} — stop Autopilot`, rows: [{ k: 'revokes', v: target }], warn: 'After this, NO new delegated approvals under this grant are accepted. Delegated approvals already made stay valid but must still be ratified.' };
|
|
1402
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
1403
|
+
if (!signed) return;
|
|
1404
|
+
glog.events.push(signed); U.writeJSON(grantsPath(p), glog);
|
|
1405
|
+
console.log('\n' + U.c.green(`✓ grant ${target} revoked`) + U.c.dim(' — Autopilot off for it. Commit ') + U.c.bold('.yaylayer/grants.json') + U.c.dim('.'));
|
|
1406
|
+
}
|
|
1407
|
+
async function cmdGrant(flags, positional) {
|
|
1408
|
+
const { p, config, lock } = loadState();
|
|
1409
|
+
if (!config) return fail('run `yay init` first');
|
|
1410
|
+
const sub = positional[0];
|
|
1411
|
+
const rlog = loadRoster(p);
|
|
1412
|
+
if (sub === 'list' || flags.list) return listGrants(p, config, lock);
|
|
1413
|
+
if (sub === 'revoke' || flags.revoke) return grantRevoke(p, config, rlog, flags, positional);
|
|
1414
|
+
if (!rlog) return fail('Autopilot needs a signed trust root — pair your phone or run `yay keygen` first.');
|
|
1415
|
+
const durMs = parseDuration((flags.for && flags.for !== true) ? flags.for : '2h');
|
|
1416
|
+
if (!durMs) return fail('bad --for duration — use e.g. 2h, 90m, 1d.');
|
|
1417
|
+
const count = (flags.count && flags.count !== true) ? parseInt(flags.count, 10) : 20;
|
|
1418
|
+
if (!(count > 0)) return fail('--count must be a positive number.');
|
|
1419
|
+
const list = (v) => (v && v !== true) ? String(v).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
1420
|
+
// P3 capability envelope. Scope constraints (cells/allow/deny/max-risk) are enforced now; content
|
|
1421
|
+
// constraints (deps/deployment) are recorded but DETECTOR-GATED — shown, enforced where a detector
|
|
1422
|
+
// exists (D15). --child-grants opts INTO attenuating sub-grants (off by default).
|
|
1423
|
+
const envelope = {};
|
|
1424
|
+
const cells = list(flags.cell);
|
|
1425
|
+
if (cells.length) envelope.cells = cells;
|
|
1426
|
+
if (list(flags.allow).length) envelope.allow = list(flags.allow);
|
|
1427
|
+
if (list(flags.deny).length) envelope.deny = list(flags.deny);
|
|
1428
|
+
if (flags['max-risk'] && flags['max-risk'] !== true) {
|
|
1429
|
+
const mr = String(flags['max-risk']).toLowerCase();
|
|
1430
|
+
if (!['low', 'medium', 'high'].includes(mr)) return fail('--max-risk must be low, medium or high.');
|
|
1431
|
+
envelope.maxRisk = mr;
|
|
1432
|
+
}
|
|
1433
|
+
if (flags.deps && flags.deps !== true) envelope.deps = String(flags.deps);
|
|
1434
|
+
if (flags.deploy && flags.deploy !== true) envelope.deployment = String(flags.deploy);
|
|
1435
|
+
if (list(flags['allow-tag']).length) envelope.allowTags = list(flags['allow-tag']);
|
|
1436
|
+
if (list(flags['deny-tag']).length) envelope.denyTags = list(flags['deny-tag']);
|
|
1437
|
+
if (flags['no-guard'] || flags.unguard) envelope.guard = false; // lift the default auth/payments/secrets/deploy/CI guard
|
|
1438
|
+
if (flags['child-grants']) envelope.childGrants = { allowed: true, maxDepth: (flags['max-depth'] && flags['max-depth'] !== true) ? parseInt(flags['max-depth'], 10) : 1 };
|
|
1439
|
+
const gk = C.generateKeypair();
|
|
1440
|
+
const glog = loadGrants(p) || { project: config.project, events: [] };
|
|
1441
|
+
const id = 'G-' + String(glog.events.filter((e) => e.type === 'grant').length + 1).padStart(3, '0');
|
|
1442
|
+
const prev = glog.events.length ? glog.events[glog.events.length - 1].id : 'genesis';
|
|
1443
|
+
const expiresAt = new Date(Date.now() + durMs).toISOString();
|
|
1444
|
+
const ev = { id, type: 'grant', grantPub: gk.pubB64, envelope, expiresAt, maxCount: count, prev, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1445
|
+
const parts = [];
|
|
1446
|
+
if (envelope.cells) parts.push(`${envelope.cells.length} named Cell(s)`);
|
|
1447
|
+
if (envelope.allow) parts.push('allow ' + envelope.allow.join(', '));
|
|
1448
|
+
if (envelope.deny) parts.push('deny ' + envelope.deny.join(', '));
|
|
1449
|
+
if (envelope.allowTags) parts.push('allow-tags ' + envelope.allowTags.join(', '));
|
|
1450
|
+
if (envelope.denyTags) parts.push('deny-tags ' + envelope.denyTags.join(', '));
|
|
1451
|
+
if (envelope.maxRisk) parts.push('max-risk ' + envelope.maxRisk);
|
|
1452
|
+
const guardOn = envelope.guard !== false;
|
|
1453
|
+
const scopeStr = parts.length ? parts.join(' · ') : 'all non-sensitive Cells';
|
|
1454
|
+
const rows = [
|
|
1455
|
+
{ k: 'scope', v: scopeStr }, { k: 'expires', v: expiresAt.slice(0, 16).replace('T', ' ') }, { k: 'max', v: `${count} delegated approvals` },
|
|
1456
|
+
{ k: 'security guard', v: guardOn ? 'ON — auth/payments/secrets/deploy/CI need a real signature' : '⚠ OFF — the AI may auto-approve auth/payments/secrets/deploy/CI' },
|
|
1457
|
+
];
|
|
1458
|
+
if (envelope.childGrants) rows.push({ k: 'child grants', v: `allowed (max depth ${envelope.childGrants.maxDepth})` });
|
|
1459
|
+
if (envelope.deps || envelope.deployment) rows.push({ k: 'recorded (not yet enforced)', v: [envelope.deps && ('deps: ' + envelope.deps), envelope.deployment && ('deploy: ' + envelope.deployment)].filter(Boolean).join(' · ') });
|
|
1460
|
+
const summary = { title: 'Grant Autopilot (delegated execution)', rows, warn: 'While active, the AI approves in-scope changes under the grant (delegated, awaiting ratification) WITHOUT contacting your phone. Sensitive / code-pinned / policy-non-delegable Cells still need a real signature. Stop anytime with `yay grant revoke`.' };
|
|
1461
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
1462
|
+
if (!signed) return; // authorizeRosterEvent already reported why
|
|
1463
|
+
glog.events.push(signed); U.writeJSON(grantsPath(p), glog);
|
|
1464
|
+
fs.mkdirSync(p.keys, { recursive: true });
|
|
1465
|
+
U.writeJSON(grantKeyPath(p, id), { pub: gk.pubB64, priv: Buffer.from(gk.privDer).toString('base64') });
|
|
1466
|
+
console.log('\n' + U.c.green(`✓ Autopilot ON — grant ${id}`) + U.c.dim(` (${scopeStr}; until ${expiresAt.slice(0, 16).replace('T', ' ')} or ${count} approvals).`));
|
|
1467
|
+
if (guardOn) console.log(' ' + U.c.dim('🔒 security guard ON — auth/payments/secrets/deploy/CI still need a real signature (lift with ') + U.c.bold('--no-guard') + U.c.dim(').'));
|
|
1468
|
+
else console.log(' ' + U.c.yellow('⚠ security guard OFF') + U.c.dim(' — this grant may auto-approve auth/payments/secrets/deploy/CI code. Re-issue without --no-guard to restore it.'));
|
|
1469
|
+
console.log(' ' + U.c.dim('the AI now approves in-scope change-sets under the grant (delegated) with no phone contact. Commit ') + U.c.bold('.yaylayer/grants.json') + U.c.dim(' (key stays in gitignored keys/).'));
|
|
1470
|
+
console.log(' ' + U.c.dim('ratify later with ') + U.c.bold('yay ratify') + U.c.dim(' · stop with ') + U.c.bold('yay grant revoke') + U.c.dim(' · check with ') + U.c.bold('yay grant list') + U.c.dim('.'));
|
|
1471
|
+
}
|
|
1472
|
+
// The human's decision surface for delegated work: the already-computed signals for a Cell, shown at
|
|
1473
|
+
// ratify so a reviewer sees complexity + smells at a glance before signing. Pure surfacing — no new
|
|
1474
|
+
// check, no stored state. Extensible: add a chip here (risk, blast radius, …) as more signals matter.
|
|
1475
|
+
function ratifyStateChip(state) {
|
|
1476
|
+
const f = state === 'GREEN' ? U.c.green : state === 'RED' ? U.c.red : state === 'YELLOW' ? U.c.yellow : U.c.gray;
|
|
1477
|
+
return f('● ' + state);
|
|
1478
|
+
}
|
|
1479
|
+
function ratifyCellFlags(r) {
|
|
1480
|
+
const chips = [];
|
|
1481
|
+
if (r.predicate && (r.predicate.undeclared || []).length) chips.push('◈ undeclared input (' + r.predicate.undeclared.join(', ') + ')');
|
|
1482
|
+
if (r.coverage && r.coverage.total && (r.coverage.missed || []).length) chips.push(r.coverage.exercised + '/' + r.coverage.total + ' branches');
|
|
1483
|
+
let inert = false, weak = false, red = null;
|
|
1484
|
+
for (const nt of (r.notes || [])) {
|
|
1485
|
+
if (/^inert code/.test(nt.text)) inert = true;
|
|
1486
|
+
else if (/weak ensures/.test(nt.text)) weak = true;
|
|
1487
|
+
else if (nt.level === 'red' && !red) red = nt.text.split(/[—;(]/)[0].trim().slice(0, 60);
|
|
1488
|
+
}
|
|
1489
|
+
if (inert) chips.push('inert');
|
|
1490
|
+
if (weak) chips.push('weak ensures');
|
|
1491
|
+
if (red) chips.push('RED: ' + red);
|
|
1492
|
+
return chips;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function cmdRatify(flags) {
|
|
1496
|
+
const { p, config, lock } = loadState();
|
|
1497
|
+
if (!config) return fail('run `yay init` first');
|
|
1498
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
1499
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
1500
|
+
const auto = R.autoCellIds(verified);
|
|
1501
|
+
if (flags.reject) return ratifyReject(p, config, lock, manifest, verified, auto, flags);
|
|
1502
|
+
if (!auto.length) { console.log(U.c.green('✓ nothing to ratify') + U.c.dim(' — no delegated Cells awaiting your signature.')); return; }
|
|
1503
|
+
const bundle = R.ratifyBundle(manifest, verified, auto);
|
|
1504
|
+
const reviewPath = path.join(path.dirname(p.lock), '.ratify-review.json');
|
|
1505
|
+
const prevReview = U.readJSON(reviewPath, null);
|
|
1506
|
+
const detail = !!(flags.d || flags.details);
|
|
1507
|
+
console.log(U.c.bold(`${auto.length} delegated Cell(s) awaiting ratification:`) + U.c.dim(detail ? '' : ' (add -d to expand spec + code)'));
|
|
1508
|
+
for (const id of auto) {
|
|
1509
|
+
const r = verified.results[id]; const c = manifest.cells[id] || {};
|
|
1510
|
+
console.log(' ' + U.c.yellow('⚡ ') + U.c.bold(id) + U.c.dim(` · ${c.unitName || ''} · grant ${r.trust.grant}`) + ' ' + ratifyStateChip(r.state));
|
|
1511
|
+
const chips = ratifyCellFlags(r);
|
|
1512
|
+
if (chips.length) console.log(' ' + U.c.dim('flags: ') + chips.map((x) => U.c.yellow(x)).join(U.c.dim(' · ')));
|
|
1513
|
+
if (prevReview && prevReview.per && prevReview.per[id] && bundle.per[id] && (prevReview.per[id].spec !== bundle.per[id].spec || prevReview.per[id].code !== bundle.per[id].code)) {
|
|
1514
|
+
console.log(' ' + U.c.yellow('↻ changed since your last review'));
|
|
1515
|
+
}
|
|
1516
|
+
if (detail) {
|
|
1517
|
+
const specTxt = (c.specBlock || '').trim(); const codeTxt = (c.unitBody || '').trim();
|
|
1518
|
+
if (specTxt) console.log(specTxt.split('\n').map((l) => ' ' + U.c.dim(l)).join('\n'));
|
|
1519
|
+
if (codeTxt) console.log(codeTxt.split('\n').slice(0, 40).map((l) => ' ' + l).join('\n'));
|
|
1520
|
+
console.log('');
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (!flags.sign && !flags.yes) {
|
|
1524
|
+
U.writeJSON(reviewPath, { hash: bundle.hash, ids: bundle.ids, per: bundle.per, at: new Date().toISOString() });
|
|
1525
|
+
console.log('\n' + U.c.dim('reviewed snapshot ') + U.c.bold(bundle.hash.slice(0, 12) + '…') + U.c.dim(' — review these, then ratify with ') + U.c.bold('yay ratify --sign') + U.c.dim(' (it signs exactly this snapshot).'));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// ── TOCTOU protection: `--sign` must sign PRECISELY what was reviewed. The reviewed hash comes
|
|
1529
|
+
// from `--reviewed <hash>` (dashboard, captured at render time) or the review file (a prior
|
|
1530
|
+
// `yay ratify`). If the delegated changes moved since, refuse the whole batch — nothing signed.
|
|
1531
|
+
let reviewed = (flags.reviewed && flags.reviewed !== true) ? String(flags.reviewed) : null, prevPer = null;
|
|
1532
|
+
if (!reviewed && fs.existsSync(reviewPath)) { const rf = U.readJSON(reviewPath, {}); reviewed = rf.hash || null; prevPer = rf.per || null; }
|
|
1533
|
+
if (!reviewed) return fail('run `yay ratify` first to review what you are signing — nothing signed.');
|
|
1534
|
+
if (reviewed !== bundle.hash) {
|
|
1535
|
+
let changed = '';
|
|
1536
|
+
if (prevPer) { const keys = Object.keys({ ...prevPer, ...bundle.per }); const diff = keys.filter((id) => !prevPer[id] || !bundle.per[id] || prevPer[id].spec !== bundle.per[id].spec || prevPer[id].code !== bundle.per[id].code); if (diff.length) changed = ' Changed: ' + diff.join(', ') + '.'; }
|
|
1537
|
+
try { if (fs.existsSync(reviewPath)) fs.unlinkSync(reviewPath); } catch (_) {}
|
|
1538
|
+
return fail(`the delegated changes moved since you reviewed them (reviewed ${String(reviewed).slice(0, 12)}…, current ${bundle.hash.slice(0, 12)}…).${changed} Run \`yay ratify\` again — nothing signed.`);
|
|
1539
|
+
}
|
|
1540
|
+
// Ratify = a normal HUMAN signature over exactly these Cells (never delegated again).
|
|
1541
|
+
flags.cell = auto.join(','); flags['no-auto'] = true;
|
|
1542
|
+
if (!flags.brief) flags.brief = 'Ratify delegated changes';
|
|
1543
|
+
const res = await cmdSign(flags);
|
|
1544
|
+
try { if (fs.existsSync(reviewPath)) fs.unlinkSync(reviewPath); } catch (_) {}
|
|
1545
|
+
return res;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Persist a REJECTION as a first-class, human-signed, append-only provenance event (P3). A rejection
|
|
1549
|
+
// is where agent autonomy failed human judgment — the substrate for future "earned autonomy" metrics
|
|
1550
|
+
// (which categories get rejected, how often). It is never erased and never rewrites history: the
|
|
1551
|
+
// delegated seal stays in the record; the rejection sits beside it, and the code stays UNSIGNED
|
|
1552
|
+
// (unratified) until fixed and re-signed. Owner-authorized (local key or phone), so it's attributable.
|
|
1553
|
+
async function ratifyReject(p, config, lock, manifest, verified, auto, flags) {
|
|
1554
|
+
const rlog = loadRoster(p);
|
|
1555
|
+
if (!rlog) return fail('rejection needs a signed trust root — pair your phone or run `yay keygen` first.');
|
|
1556
|
+
const want = (flags.cell && flags.cell !== true) ? String(flags.cell).split(',').map((s) => s.trim()).filter(Boolean) : auto;
|
|
1557
|
+
const cells = want.filter((id) => auto.includes(id));
|
|
1558
|
+
if (!cells.length) return fail(auto.length ? `none of those Cells are awaiting ratification — pending: ${auto.join(', ')}.` : 'nothing to reject — no delegated Cells awaiting ratification.');
|
|
1559
|
+
const reason = (flags.reason && flags.reason !== true) ? String(flags.reason) : null;
|
|
1560
|
+
if (!reason) return fail('a rejection needs a reason — pass --reason "<why>" (recorded, tamper-evident). Optionally --category <dependency|security|scope|quality|other>.');
|
|
1561
|
+
const category = (flags.category && flags.category !== true) ? String(flags.category).toLowerCase() : 'other';
|
|
1562
|
+
const specHashes = {}; const grantsHit = new Set();
|
|
1563
|
+
for (const id of cells) { specHashes[id] = (manifest.cells[id] || {}).specHash || null; const t = (verified.results[id] || {}).trust || {}; if (t.grant) grantsHit.add(t.grant); }
|
|
1564
|
+
const rj = loadRejections(p) || { project: config.project, events: [] };
|
|
1565
|
+
const id = 'X-' + String(rj.events.length + 1).padStart(4, '0');
|
|
1566
|
+
const ev = { id, type: 'reject', project: config.project, cells, specHashes, grants: [...grantsHit], reason, category, prev: rj.events.length ? rj.events[rj.events.length - 1].id : 'genesis', nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1567
|
+
const summary = { title: `Reject ${cells.length} delegated Cell(s)`, rows: [
|
|
1568
|
+
{ k: 'cells', v: cells.join(', ') }, { k: 'category', v: category }, { k: 'reason', v: reason },
|
|
1569
|
+
], warn: 'This records a signed REJECTION (append-only). The delegated code stays UNSIGNED until fixed and re-signed; the rejection is kept forever as provenance (and feeds earned-autonomy metrics).' };
|
|
1570
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
1571
|
+
if (!signed) return; // authorizeRosterEvent already reported why
|
|
1572
|
+
signed.signer = signed.by; // the human who rejected
|
|
1573
|
+
rj.events.push(signed); U.writeJSON(rejectionsPath(p), rj);
|
|
1574
|
+
console.log('\n' + U.c.red(`✗ rejected ${cells.length} Cell(s)`) + U.c.dim(` — ${id} · ${category} · by ${signed.by}. Recorded in `) + U.c.bold('.yaylayer/rejections.json') + U.c.dim(' (commit it). The code stays unsigned until fixed.'));
|
|
1575
|
+
}
|
|
1576
|
+
function loadRejections(p) { return U.readJSON(rejectionsPath(p), null); }
|
|
1577
|
+
|
|
1578
|
+
async function cmdPair(flags) {
|
|
1579
|
+
const { p, config } = loadState();
|
|
1580
|
+
if (!config) return fail('run `yay init` first');
|
|
1581
|
+
const ok = await runPairing(p, config, flags);
|
|
1582
|
+
if (ok) console.log(' ' + U.c.dim('now approve change-sets with ') + U.c.bold('yay sign --phone'));
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Enroll another signer — an OWNER-signed event, so the AI (which lacks an owner
|
|
1586
|
+
// key) can never add a signer by editing files. (Owner authorizes with their local
|
|
1587
|
+
// key here; phone-authorized enrollment is the next increment.)
|
|
1588
|
+
async function cmdEnroll(flags) {
|
|
1589
|
+
const { p, config } = loadState();
|
|
1590
|
+
if (!config) return fail('run `yay init` first');
|
|
1591
|
+
const log = loadRoster(p);
|
|
1592
|
+
if (!log) return fail('no signed roster yet — create the first key (`yay init` / `yay keygen`) to establish the trust root, then enroll others.');
|
|
1593
|
+
const name = (flags.name && flags.name !== true) ? flags.name : null;
|
|
1594
|
+
const pub = (flags.pubkey && flags.pubkey !== true) ? flags.pubkey : ((flags.pub && flags.pub !== true) ? flags.pub : null);
|
|
1595
|
+
if (!name || !pub) return fail('usage: yay enroll --name "Alice Carlsen" --pubkey <base64> [--role owner|signer] [--phone]');
|
|
1596
|
+
const role = flags.role === 'owner' ? 'owner' : 'signer';
|
|
1597
|
+
const drv = rosterMod.deriveRoster(log);
|
|
1598
|
+
const type = drv.roster[name] ? 'add-key' : 'add-signer';
|
|
1599
|
+
const ev = { id: rosterMod.nextEventId(log), type, name, pub, role, by: null, prev: log.events[log.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1600
|
+
// The 6-digit confirm code binds this pubkey to the right human (anti-MITM): the
|
|
1601
|
+
// joiner sees the same code on their screen; the owner verifies it out-of-band.
|
|
1602
|
+
const code = String(parseInt(C.sha256('yay-pair:' + pub).slice(0, 8), 16) % 1000000).padStart(6, '0');
|
|
1603
|
+
const summary = {
|
|
1604
|
+
title: (type === 'add-key' ? `Add another key for "${name}" in ` : `Add ${role} "${name}" to `) + config.project + '?',
|
|
1605
|
+
rows: [
|
|
1606
|
+
{ k: name + ' · ' + role, v: 'key ' + rosterMod.fingerprint(pub) },
|
|
1607
|
+
{ k: 'confirm code', v: code + ' — must match the joiner’s screen' },
|
|
1608
|
+
],
|
|
1609
|
+
warn: role === 'owner' ? 'Owner rights: they can enroll and revoke signers.' : '',
|
|
1610
|
+
};
|
|
1611
|
+
const signed = await authorizeRosterEvent(p, config, log, ev, flags, summary);
|
|
1612
|
+
if (!signed) return;
|
|
1613
|
+
const test = rosterMod.deriveRoster({ ...log, events: log.events.concat([signed]) });
|
|
1614
|
+
if (test.problems.length) return fail('refusing to write — event would not authorize: ' + test.problems.join('; '));
|
|
1615
|
+
log.events.push(signed);
|
|
1616
|
+
U.writeJSON(rosterPath(p), log);
|
|
1617
|
+
addSignerKey(config, name, pub, role === 'owner' ? 'owner' : 'signer'); // mirror for convenience
|
|
1618
|
+
U.writeJSON(p.config, config);
|
|
1619
|
+
console.log(U.c.green(`✓ enrolled "${name}" as ${role}`) + U.c.dim(` (authorized by ${signed.by}) — commit .yaylayer/roster.json`));
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// `yay invite "Bob"` — a teammate opens a link, creates their key, and requests to
|
|
1623
|
+
// join; you approve on your phone. The name is an optional SUGGESTION that pre-fills
|
|
1624
|
+
// the joiner's (editable) name field. No new trust surface: it triggers the normal
|
|
1625
|
+
// owner-signed enroll. Uses the project's transport: RELAY (works from anywhere,
|
|
1626
|
+
// self-contained) or LAN (via a running dashboard, same network).
|
|
1627
|
+
async function cmdInvite(flags, positional) {
|
|
1628
|
+
const { p, config } = loadState();
|
|
1629
|
+
if (!config) return fail('run `yay init` first');
|
|
1630
|
+
const name = (positional && positional[0]) ? positional[0] : ((flags.name && flags.name !== true) ? flags.name : '');
|
|
1631
|
+
const role = flags.role === 'owner' ? 'owner' : 'signer';
|
|
1632
|
+
if (phoneTransport(config, flags) === 'relay') return inviteViaRelay(p, config, name, role, flags);
|
|
1633
|
+
return inviteViaDashboard(p, config, name, role, flags);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// RELAY invite: self-contained (no dashboard needed). Mint a one-time invite channel,
|
|
1637
|
+
// print a relay.yaylayer.com link, wait for the joiner's key over the relay, then run
|
|
1638
|
+
// the normal owner-signed enroll (which routes YOUR approval to your relay-paired phone).
|
|
1639
|
+
async function inviteViaRelay(p, config, name, role, flags) {
|
|
1640
|
+
const rlog = loadRoster(p);
|
|
1641
|
+
if (!rlog || !rlog.events || !rlog.events.length) return fail('no signed roster yet — run `yay init` to establish the trust root first.');
|
|
1642
|
+
const base = relayBase(flags);
|
|
1643
|
+
const ich = E2E.newChannel();
|
|
1644
|
+
const ikeyBytes = E2E.newKey();
|
|
1645
|
+
const isess = { base, channel: ich, keyBytes: ikeyBytes };
|
|
1646
|
+
const challenge = C.randomNonce();
|
|
1647
|
+
const offer = { mode: 'join', name, role, project: config.project, challenge };
|
|
1648
|
+
try {
|
|
1649
|
+
const rq = await relayFetch(isess, '/api/request', { method: 'POST', body: JSON.stringify({ ch: ich, blob: E2E.seal(ikeyBytes, offer) }) });
|
|
1650
|
+
if (!rq.ok) return fail('the relay rejected the invite (HTTP ' + rq.status + ').');
|
|
1651
|
+
} catch (e) { return fail('could not reach the relay (' + base + '): ' + ((e && e.message) || e)); }
|
|
1652
|
+
const url = base + '/#' + ich + '.' + E2E.b64url(ikeyBytes);
|
|
1653
|
+
console.log('\n' + U.c.green('✓ invite' + (name ? ` for "${name}"` : '')) + U.c.dim(` as ${role} — over the relay, end-to-end encrypted.`));
|
|
1654
|
+
console.log(' ' + U.c.bold('send this link → ') + U.c.accent(url));
|
|
1655
|
+
console.log(U.c.dim(' …or have them scan:'));
|
|
1656
|
+
printQR(url);
|
|
1657
|
+
console.log(U.c.dim(` They open it anywhere, set their name (${name ? `pre-filled "${name}", ` : ''}editable), and create their key.`));
|
|
1658
|
+
console.log(U.c.dim(' Then approve on the phone you already have on the relay page. Ctrl-C to cancel.'));
|
|
1659
|
+
// Wait for the joiner's answer over the relay.
|
|
1660
|
+
let ans = null;
|
|
1661
|
+
for (;;) {
|
|
1662
|
+
let r; try { r = await relayFetch(isess, '/api/result?ch=' + encodeURIComponent(ich)).then((x) => x.json()); } catch (_) { await sleepMs(1500); continue; }
|
|
1663
|
+
if (r && r.state === 'answered') { ans = E2E.open(ikeyBytes, r.blob); break; }
|
|
1664
|
+
await sleepMs(1500);
|
|
1665
|
+
}
|
|
1666
|
+
if (!ans) { await relayFinal(isess, { ok: false, reason: 'could not read the request' }); return fail('could not decrypt the join request (channel key mismatch).'); }
|
|
1667
|
+
const finalName = String(ans.name || name || '').trim();
|
|
1668
|
+
if (!finalName || !ans.pubB64 || !ans.proof || !C.verify(challenge, ans.proof, ans.pubB64)) {
|
|
1669
|
+
await relayFinal(isess, { ok: false, reason: 'the request did not verify' });
|
|
1670
|
+
return fail('the join request did not verify — nothing was written.');
|
|
1671
|
+
}
|
|
1672
|
+
console.log('\n' + U.c.bold(`${finalName} wants to join`) + U.c.dim(` — key ${rosterMod.fingerprint(ans.pubB64)}. Approve on your phone…`));
|
|
1673
|
+
// Build the add-signer event and route the OWNER approval to the phone (relay).
|
|
1674
|
+
const drv = rosterMod.deriveRoster(rlog);
|
|
1675
|
+
const type = drv.roster[finalName] ? 'add-key' : 'add-signer';
|
|
1676
|
+
const code = String(parseInt(C.sha256('yay-pair:' + ans.pubB64).slice(0, 8), 16) % 1000000).padStart(6, '0');
|
|
1677
|
+
const ev = { id: rosterMod.nextEventId(rlog), type, name: finalName, pub: ans.pubB64, role, by: null, prev: rlog.events[rlog.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1678
|
+
const summary = {
|
|
1679
|
+
title: (type === 'add-key' ? `Add another key for "${finalName}" in ` : `Add ${role} "${finalName}" to `) + config.project + '?',
|
|
1680
|
+
rows: [{ k: finalName + ' · ' + role, v: 'key ' + rosterMod.fingerprint(ans.pubB64) }, { k: 'confirm code', v: code + ' — must match the joiner’s screen' }],
|
|
1681
|
+
warn: role === 'owner' ? 'Owner rights: they can enroll and revoke signers.' : '',
|
|
1682
|
+
};
|
|
1683
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
1684
|
+
if (!signed) { await relayFinal(isess, { ok: false, reason: 'the owner did not approve' }); return; }
|
|
1685
|
+
const test = rosterMod.deriveRoster({ ...rlog, events: rlog.events.concat([signed]) });
|
|
1686
|
+
if (test.problems.length) { await relayFinal(isess, { ok: false, reason: 'roster would not validate' }); return fail('refusing to write — ' + test.problems.join('; ')); }
|
|
1687
|
+
rlog.events.push(signed);
|
|
1688
|
+
U.writeJSON(rosterPath(p), rlog);
|
|
1689
|
+
addSignerKey(config, finalName, ans.pubB64, role === 'owner' ? 'owner' : 'signer');
|
|
1690
|
+
U.writeJSON(p.config, config);
|
|
1691
|
+
await relayFinal(isess, { ok: true, message: 'Approved — you’re in! You can start signing on this phone.' });
|
|
1692
|
+
console.log(U.c.green(`✓ enrolled "${finalName}" as ${role}`) + U.c.dim(` (authorized by ${signed.by}) — commit .yaylayer/roster.json`));
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// LAN invite: via a running dashboard (same-network joiner).
|
|
1696
|
+
async function inviteViaDashboard(p, config, name, role, flags) {
|
|
1697
|
+
const info = dashboardReg(p);
|
|
1698
|
+
if (!info) return fail('no running dashboard found — start `yay dashboard` in another terminal, then run `yay invite` here.');
|
|
1699
|
+
let ping; try { ping = await dfetch(info, '/api/ping').then((r) => r.json()); } catch (_) { ping = null; }
|
|
1700
|
+
if (!ping || ping.yay !== 'dashboard') return fail('the dashboard is not reachable — is `yay dashboard` still running?');
|
|
1701
|
+
let r; try { r = await dfetch(info, '/api/invite/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role }) }).then((x) => x.json()); }
|
|
1702
|
+
catch (e) { return fail('could not reach the dashboard: ' + ((e && e.message) || e)); }
|
|
1703
|
+
if (!r || !r.token) return fail('the dashboard did not issue an invite' + (r && r.error ? ': ' + r.error : ''));
|
|
1704
|
+
const base = (info.phoneUrl || '').replace(/\/phone$/, '') || `${info.scheme}://<this-mac>:${info.port}`;
|
|
1705
|
+
const joinUrl = base + r.joinPath;
|
|
1706
|
+
console.log('\n' + U.c.green('✓ invite' + (name ? ` for "${name}"` : '')) + U.c.dim(` as ${role} — valid ${r.expiresInMin || 30} min, one-time.`));
|
|
1707
|
+
console.log(' ' + U.c.bold('send this link → ') + U.c.accent(joinUrl));
|
|
1708
|
+
console.log(U.c.dim(' …or have them scan (same Wi-Fi):'));
|
|
1709
|
+
printQR(joinUrl);
|
|
1710
|
+
console.log(U.c.dim(` They open it, confirm their name (${name ? `pre-filled "${name}", ` : ''}editable), and create their key.`));
|
|
1711
|
+
console.log(U.c.dim(' Then an approval pops up on your phone — verify the 6-digit code together, then tap Approve.'));
|
|
1712
|
+
if (role === 'owner') console.log(U.c.yellow(' ⚠ owner role: they will be able to enroll and revoke others.'));
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
async function cmdRevoke(flags) {
|
|
1716
|
+
const { p, config } = loadState();
|
|
1717
|
+
if (!config) return fail('run `yay init` first');
|
|
1718
|
+
const log = loadRoster(p);
|
|
1719
|
+
if (!log || !log.events || !log.events.length) return fail('no signed roster to revoke from');
|
|
1720
|
+
const name = (flags.name && flags.name !== true) ? flags.name : null;
|
|
1721
|
+
if (!name) return fail('usage: yay revoke --name "Alice" [--pubkey <base64>] [--phone] (omit --pubkey to remove the whole identity)');
|
|
1722
|
+
const drv = rosterMod.deriveRoster(log);
|
|
1723
|
+
if (!drv.roster[name]) return fail(`"${name}" is not in the roster`);
|
|
1724
|
+
const pub = (flags.pubkey && flags.pubkey !== true) ? flags.pubkey : ((flags.pub && flags.pub !== true) ? flags.pub : null);
|
|
1725
|
+
if (pub && !drv.roster[name].includes(pub)) return fail(`that key is not one of ${name}'s keys`);
|
|
1726
|
+
|
|
1727
|
+
// Early lockout check: never leave the project with zero owner keys.
|
|
1728
|
+
const simRoster = {}, simRoles = {};
|
|
1729
|
+
for (const nm of Object.keys(drv.roster)) simRoster[nm] = drv.roster[nm].slice();
|
|
1730
|
+
Object.assign(simRoles, drv.roles);
|
|
1731
|
+
if (pub) { simRoster[name] = simRoster[name].filter((k) => k !== pub); if (!simRoster[name].length) { delete simRoster[name]; delete simRoles[name]; } }
|
|
1732
|
+
else { delete simRoster[name]; delete simRoles[name]; }
|
|
1733
|
+
const ownerKeysLeft = Object.keys(simRoles).filter((nm) => simRoles[nm] === 'owner').reduce((a, nm) => a + (simRoster[nm] || []).length, 0);
|
|
1734
|
+
if (!ownerKeysLeft) return fail('refusing — that would leave the roster with NO owner key (governance lockout). Enroll another owner first, then revoke.');
|
|
1735
|
+
|
|
1736
|
+
const ev = pub
|
|
1737
|
+
? { id: rosterMod.nextEventId(log), type: 'revoke-key', name, pub, by: null, prev: log.events[log.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() }
|
|
1738
|
+
: { id: rosterMod.nextEventId(log), type: 'remove-signer', name, by: null, prev: log.events[log.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
1739
|
+
const summary = pub
|
|
1740
|
+
? { title: `Revoke a key of "${name}" in ${config.project}?`, rows: [{ k: name, v: 'key ' + rosterMod.fingerprint(pub) }], warn: 'That key can no longer sign after this. Past approvals stay attributed.' }
|
|
1741
|
+
: { title: `Remove signer "${name}" from ${config.project}?`, rows: [{ k: name, v: drv.roster[name].length + ' key(s)' }], warn: 'All of their keys lose signing rights. Past approvals stay attributed.' };
|
|
1742
|
+
const signed = await authorizeRosterEvent(p, config, log, ev, flags, summary);
|
|
1743
|
+
if (!signed) return;
|
|
1744
|
+
const test = rosterMod.deriveRoster({ ...log, events: log.events.concat([signed]) });
|
|
1745
|
+
if (test.problems.length) return fail('refusing to write — ' + test.problems.join('; '));
|
|
1746
|
+
log.events.push(signed);
|
|
1747
|
+
U.writeJSON(rosterPath(p), log);
|
|
1748
|
+
// Mirror into config for display (best-effort).
|
|
1749
|
+
if (config.signers && config.signers[name]) {
|
|
1750
|
+
if (pub) { config.signers[name] = U.pubKeysOf(config.signers[name]).filter((k) => k !== pub).map((k) => ({ pub: k })); if (!config.signers[name].length) { delete config.signers[name]; if (config.owners) config.owners = config.owners.filter((o) => o !== name); } }
|
|
1751
|
+
else { delete config.signers[name]; if (config.owners) config.owners = config.owners.filter((o) => o !== name); }
|
|
1752
|
+
U.writeJSON(p.config, config);
|
|
1753
|
+
}
|
|
1754
|
+
console.log(U.c.green(`✓ ${pub ? 'revoked a key of' : 'removed'} "${name}"`) + U.c.dim(` (authorized by ${signed.by}) — commit .yaylayer/roster.json. Update CI if you pinned this key.`));
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
async function cmdReroot(flags) {
|
|
1758
|
+
const { p, config } = loadState();
|
|
1759
|
+
if (!config) return fail('run `yay init` first');
|
|
1760
|
+
const log = loadRoster(p);
|
|
1761
|
+
const oldFp = (log && log.events && log.events.length) ? rosterMod.deriveRoster(log).rootFp : null;
|
|
1762
|
+
console.log(U.c.yellow('⚠ Re-root') + ' establishes a BRAND-NEW trust root and retires the current one.');
|
|
1763
|
+
console.log(U.c.dim(' This is a trust DISCONTINUITY: signers under the old root are dropped, the CI'));
|
|
1764
|
+
console.log(U.c.dim(' --root pin must be repointed, and existing specs must be re-signed under the new'));
|
|
1765
|
+
console.log(U.c.dim(' key. Provenance is preserved — the old roster is ARCHIVED and past signatures stay'));
|
|
1766
|
+
console.log(U.c.dim(' valid against it; this is a labeled seam, not a wipe. Use only when the root key is lost/compromised.'));
|
|
1767
|
+
if (oldFp) console.log(U.c.dim(' current root → ') + U.c.bold(oldFp));
|
|
1768
|
+
// Team guard: if OTHER owners exist, the clean path is revoke+enroll (keeps the root). Don't refuse
|
|
1769
|
+
// (all owners may be lost/compromised) — but escalate the confirmation to a deliberate token.
|
|
1770
|
+
const drv0 = (log && log.events && log.events.length) ? rosterMod.deriveRoster(log) : { roles: {} };
|
|
1771
|
+
const owners = Object.keys(drv0.roles || {}).filter((n) => drv0.roles[n] === 'owner');
|
|
1772
|
+
const teamGuard = owners.length > 1;
|
|
1773
|
+
if (teamGuard) {
|
|
1774
|
+
console.log('\n ' + U.c.yellow(`⚠ This project has ${owners.length} owners: `) + U.c.bold(owners.join(', ')));
|
|
1775
|
+
console.log(U.c.dim(' Normally another owner should recover you instead — it keeps the root, no re-signing:'));
|
|
1776
|
+
console.log(' ' + U.c.bold(`yay revoke --name "<you>"`) + U.c.dim(' then ') + U.c.bold('yay enroll --name "<you>" --pubkey <new>'));
|
|
1777
|
+
console.log(U.c.dim(' Only reroot if EVERY owner key is lost or compromised and no one can revoke.'));
|
|
1778
|
+
}
|
|
1779
|
+
// No CI pin → this reroot (and any) has no backstop. Warn loudly.
|
|
1780
|
+
if (!fs.existsSync(path.join(p.root, '.github', 'workflows', 'yaylayer.yml'))) {
|
|
1781
|
+
console.log('\n ' + U.c.yellow('⚠ No CI gate found') + U.c.dim(' — without a pinned trust root in CI, a reroot has no backstop and is not detectable by others. Set up `yay gate` for real protection.'));
|
|
1782
|
+
}
|
|
1783
|
+
if (!flags.force) {
|
|
1784
|
+
const token = teamGuard ? String(config.project || 'reroot') : 'reroot';
|
|
1785
|
+
const prompt = teamGuard
|
|
1786
|
+
? ` Type the project name "${token}" to confirm all owners are unavailable and proceed: `
|
|
1787
|
+
: ' Type "reroot" to confirm: ';
|
|
1788
|
+
const ans = await ask(prompt);
|
|
1789
|
+
if ((ans || '').trim() !== token) return console.log(' aborted — nothing changed.');
|
|
1790
|
+
}
|
|
1791
|
+
// A stated reason, recorded in the audit (e.g. "Compromised keys during cyberattack 2027-06-24"
|
|
1792
|
+
// or "Keys lost"). Every event is ISO-timestamped, so the rotation is dated + attributed.
|
|
1793
|
+
let reason = (flags.reason && flags.reason !== true) ? String(flags.reason).trim() : '';
|
|
1794
|
+
if (!reason && process.stdin.isTTY) reason = (await ask(' Reason for rerooting (recorded in the audit, e.g. "keys lost" / "compromised in breach"): ')).trim();
|
|
1795
|
+
const rerootedAt = new Date().toISOString();
|
|
1796
|
+
const genesisMeta = { supersedes: oldFp || null, rerootedAt, rerootReason: reason || '(unstated)' };
|
|
1797
|
+
// Archive the old roster + reset the config mirror; the new genesis re-populates it.
|
|
1798
|
+
if (log) { U.writeJSON(rosterPath(p).replace(/\.json$/, `.${oldFp || 'old'}.json`), log); fs.unlinkSync(rosterPath(p)); }
|
|
1799
|
+
config.signers = {}; config.owners = [];
|
|
1800
|
+
config.lastReroot = { from: oldFp || null, reason: reason || '(unstated)', at: rerootedAt }; // committed audit note (both paths)
|
|
1801
|
+
U.writeJSON(p.config, config);
|
|
1802
|
+
console.log('\n' + U.c.bold('Establish the new trust root:'));
|
|
1803
|
+
if (flags.phone || flags.key === 'mobile') {
|
|
1804
|
+
await runPairing(p, config, flags, genesisMeta); // no roster now → phone-as-genesis (stamped)
|
|
1805
|
+
} else {
|
|
1806
|
+
const name = (flags.name && flags.name !== true) ? flags.name : (process.stdin.isTTY ? await ask(' New owner name: ') : 'you');
|
|
1807
|
+
const pass = await getPassphrase(flags, `Set a passphrase for the new owner key "${name}"`);
|
|
1808
|
+
if (!pass || pass.length < 6) return fail('passphrase must be at least 6 characters — no new root created (old one archived).');
|
|
1809
|
+
createKey(p, config, name, pass, genesisMeta);
|
|
1810
|
+
console.log(U.c.green(`✓ new local owner key for "${name}"`));
|
|
1811
|
+
}
|
|
1812
|
+
console.log(' ' + U.c.dim('rotation recorded: from ') + U.c.bold(oldFp || '(none)') + U.c.dim(' · ') + U.c.bold(rerootedAt.slice(0, 16).replace('T', ' ')) + U.c.dim(' · reason: ') + U.c.bold(reason || '(unstated)'));
|
|
1813
|
+
console.log('\n' + U.c.bold('Next:') + U.c.dim(' re-sign specs under the new root ') + U.c.bold('yay sign --all') + U.c.dim(', then repoint CI ') + U.c.bold('yay gate') + U.c.dim(' (new fingerprint above).'));
|
|
1814
|
+
if (oldFp) console.log(U.c.dim(' old roster archived → .yaylayer/roster.' + oldFp + '.json') + U.c.dim(' (past signatures stay valid against it — provenance preserved).'));
|
|
1815
|
+
// The foundation seal was signed under the OLD root; it's now orphaned. config.foundation
|
|
1816
|
+
// survived (posture persists), so verify will flag "expected but missing" until you re-seal.
|
|
1817
|
+
if (config.foundation && config.foundation !== 'off') {
|
|
1818
|
+
console.log(' ' + U.c.yellow('⚠ re-seal the foundation') + U.c.dim(' under the new root: ') + U.c.bold('yay protect') + U.c.dim(` (posture ${config.foundation} — the old seal no longer applies).`));
|
|
1819
|
+
}
|
|
1820
|
+
if (teamGuard) console.log(' ' + U.c.yellow('⚠ tell your team the trust root rotated') + U.c.dim(' — everyone re-clones/re-pairs against the new root ') + U.c.bold(rosterMod.deriveRoster(loadRoster(p)).rootFp || '') + U.c.dim('. Anyone still on the old root will see TRUST-ROOT MISMATCH.'));
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function printReport(manifest, verified, details, problemsOnly) {
|
|
1824
|
+
for (const prob of manifest.problems) {
|
|
1825
|
+
console.log(U.c.red(' ✗ ') + `${prob.id || ''} ${prob.file || ''} — ${prob.error}`);
|
|
1826
|
+
}
|
|
1827
|
+
const allIds = Object.keys(verified.results).sort();
|
|
1828
|
+
const ids = problemsOnly ? allIds.filter((id) => verified.results[id].state !== 'GREEN') : allIds;
|
|
1829
|
+
if (problemsOnly && !ids.length && !manifest.problems.length) {
|
|
1830
|
+
console.log(' ' + U.c.green(`✓ all ${allIds.length} Cell(s) green — nothing to review.`));
|
|
1831
|
+
}
|
|
1832
|
+
const ind = ' ';
|
|
1833
|
+
for (const id of ids) {
|
|
1834
|
+
const r = verified.results[id];
|
|
1835
|
+
const st = U.STATE[r.state];
|
|
1836
|
+
const who = r.trust && r.trust.signed ? (r.trust.auto ? 'auto' : r.trust.signer) : '';
|
|
1837
|
+
console.log(' ' + st.color(st.glyph) + ' ' + st.color(r.state.padEnd(8)) + ' ' +
|
|
1838
|
+
U.c.accent(id.padEnd(8)) + ' ' + U.c.dim(`${r.file}:${r.line}`) + (who ? U.c.dim(' · ' + who) : ''));
|
|
1839
|
+
if (r.notes.length) for (const note of r.notes) {
|
|
1840
|
+
const paint = note.level === 'red' ? U.c.red : note.level === 'yellow' ? U.c.yellow : U.c.dim;
|
|
1841
|
+
const sym = note.level === 'red' ? '✗' : note.level === 'yellow' ? '⚠' : '–';
|
|
1842
|
+
console.log(' ' + paint(sym + ' ' + note.text));
|
|
1843
|
+
} else if (details) console.log(' ' + U.c.green('✓ all checks passed'));
|
|
1844
|
+
|
|
1845
|
+
if (details) {
|
|
1846
|
+
const cell = manifest.cells[id];
|
|
1847
|
+
if (cell) {
|
|
1848
|
+
console.log(' ' + U.c.accent('spec'));
|
|
1849
|
+
for (const l of (cell.specBlock || '').split('\n')) console.log(ind + U.c.dim(l));
|
|
1850
|
+
if (cell.unitBody) {
|
|
1851
|
+
const bad = new Set((r.badLines || []).map((s) => s.trim()));
|
|
1852
|
+
console.log(' ' + U.c.accent('code'));
|
|
1853
|
+
for (const l of cell.unitBody.split('\n')) {
|
|
1854
|
+
const isBad = l.trim() && bad.has(l.trim());
|
|
1855
|
+
console.log(ind + (isBad ? U.c.red('▶ ' + l) : U.c.dim(l)));
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
const meta = [];
|
|
1859
|
+
const when = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '');
|
|
1860
|
+
if (r.trust && r.trust.signed) {
|
|
1861
|
+
meta.push((r.trust.auto ? 'Delegated·' + (r.trust.grant || 'grant') : 'by ' + r.trust.signer) + (r.trust.at ? ' on ' + when(r.trust.at) : ''));
|
|
1862
|
+
} else meta.push('unsigned');
|
|
1863
|
+
if (r.trust && r.trust.firstAt && r.trust.firstAt !== r.trust.at) meta.push('first signed ' + when(r.trust.firstAt));
|
|
1864
|
+
meta.push('spec ' + cell.specHash.slice(0, 12) + '…');
|
|
1865
|
+
if (cell.feeds && cell.feeds.length) meta.push('feeds → ' + cell.feeds.join(', '));
|
|
1866
|
+
if (cell.contains && cell.contains.length) meta.push('contains ' + cell.contains.join(', '));
|
|
1867
|
+
console.log(' ' + U.c.accent('meta') + ' ' + U.c.dim(meta.join(' · ')));
|
|
1868
|
+
}
|
|
1869
|
+
console.log('');
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const c = verified.counts;
|
|
1873
|
+
console.log('\n ' + U.c.green(`${c.GREEN} green`) + ' ' + U.c.yellow(`${c.YELLOW} yellow`) + ' ' +
|
|
1874
|
+
U.c.red(`${c.RED} red`) + ' ' + U.c.gray(`${c.UNSIGNED} unsigned`) + ' ' + U.c.pink(`${c.PINK} pink`));
|
|
1875
|
+
// Legibility: green ≠ proven. Show how many green Cells are machine-proven vs only signed.
|
|
1876
|
+
if (c.GREEN) console.log(' ' + U.c.dim('of green: ') + U.c.green(`${c.proven || 0} machine-proven`) + U.c.dim(' · ') +
|
|
1877
|
+
(c.unproven ? U.c.yellow(`${c.unproven} signed but unproven`) + U.c.dim(' — strengthen these `ensures` (they carry a promise nothing checks)') : U.c.dim('0 unproven')));
|
|
1878
|
+
if (c.PINK) console.log(' ' + U.c.pink(`◆ ${c.PINK} unspecified code section(s) — never described or signed. Run `) + U.c.bold('yay adopt') + U.c.pink('.'));
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function cmdVerify(flags) {
|
|
1882
|
+
const { p, config, lock } = loadState();
|
|
1883
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
1884
|
+
if (!Object.keys(manifest.cells).length && !manifest.problems.length && !(manifest.untracked || []).length) {
|
|
1885
|
+
console.log(U.c.dim('no code found. Write a spec block (see README/STANDARD), or run `yay adopt`.')); return;
|
|
1886
|
+
}
|
|
1887
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: !flags['no-mutate'], roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
1888
|
+
printReport(manifest, verified, !!(flags.details || flags.d), !!(flags.problems || flags.issues || flags.p));
|
|
1889
|
+
if (verified.signedRoster) console.log(' ' + U.c.dim('trust root ' + verified.rootFp) + (verified.rootMeta ? U.c.dim(' · rerooted' + (verified.rootMeta.supersedes ? ' from ' + verified.rootMeta.supersedes : '') + (verified.rootMeta.rerootedAt ? ' on ' + String(verified.rootMeta.rerootedAt).slice(0, 10) : '') + ' — ' + (verified.rootMeta.rerootReason || '(unstated)')) : ''));
|
|
1890
|
+
else console.log(' ' + U.c.yellow('⚠ roster is unsigned') + U.c.dim(' — no signed trust root; run `yay init`/`yay keygen` to establish one.'));
|
|
1891
|
+
for (const pb of verified.rosterProblems || []) console.log(' ' + U.c.red('✗ roster: ') + pb);
|
|
1892
|
+
reportFoundation(verified);
|
|
1893
|
+
// Verifier attestation status (P2): is the current tree covered by a signed attestation?
|
|
1894
|
+
reportAttestStatus(p, config, manifest, verified);
|
|
1895
|
+
const rvGate = reportReverifyPosture(p, config, manifest, verified);
|
|
1896
|
+
reportProtection(p, config, verified);
|
|
1897
|
+
const blocked = !verified.passed || manifest.problems.length || (rvGate && rvGate.posture === 'strict' && !rvGate.satisfied);
|
|
1898
|
+
console.log('\n ' + (blocked ? U.c.red('GATE: BLOCKED') + U.c.dim(' (red, unsigned, or unspecified/pink code cannot reach main)')
|
|
1899
|
+
: U.c.green('GATE: PASS')));
|
|
1900
|
+
if (!flags.dir && process.argv.includes('--strict')) process.exit(blocked ? 1 : 0);
|
|
1901
|
+
if (flags.strict) process.exit(blocked ? 1 : 0);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// One line under `yay verify`: whether a signed verifier attestation covers the EXACT current tree.
|
|
1905
|
+
// Drift (code/spec changed since the last attestation) is surfaced so the human knows the signed
|
|
1906
|
+
// verdict no longer describes what's on disk — re-mint with `yay attest`.
|
|
1907
|
+
// Foundation seal status — REVEAL any drift in the fixed core (warn in Guarded, block in Strict).
|
|
1908
|
+
function reportFoundation(verified) {
|
|
1909
|
+
const fnd = verified.foundation;
|
|
1910
|
+
if (!fnd) return;
|
|
1911
|
+
if (fnd.clean) { console.log(' ' + U.c.dim('🛡 foundation seal intact (' + fnd.mode + ')')); return; }
|
|
1912
|
+
if (fnd.expectedButMissing) {
|
|
1913
|
+
console.log(' ' + U.c.red('🛡 FOUNDATION PROTECTION EXPECTED — no valid seal under the current root') + U.c.dim(' — run ') + U.c.bold('yay protect') + U.c.dim(' to (re-)seal.' + (fnd.mode === 'strict' ? ' (strict → gate blocked)' : '')));
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const bits = [];
|
|
1917
|
+
if (fnd.changed.length) bits.push(fnd.changed.length + ' changed (' + fnd.changed.join(', ') + ')');
|
|
1918
|
+
if (fnd.missing.length) bits.push(fnd.missing.length + ' missing (' + fnd.missing.join(', ') + ')');
|
|
1919
|
+
if (fnd.addedFiles.length) bits.push(fnd.addedFiles.length + ' new file(s) (' + fnd.addedFiles.join(', ') + ')');
|
|
1920
|
+
if (fnd.removedFiles.length) bits.push(fnd.removedFiles.length + ' removed (' + fnd.removedFiles.join(', ') + ')');
|
|
1921
|
+
console.log(' ' + U.c.red('⚠ FOUNDATION CHANGED') + U.c.dim(' — ' + bits.join(' · ')));
|
|
1922
|
+
console.log(' ' + U.c.dim('a sealed core file (or watched zone) changed since it was sealed. If this was you, re-seal: ') + U.c.bold('yay protect') + U.c.dim('. If not, investigate — this reveals tampering or corruption.' + (fnd.mode === 'strict' ? ' (strict → gate BLOCKED until re-sealed)' : ' (guarded → warning only)')));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Protection posture — an honest "are you actually protected?" readout so advisory-only can't be
|
|
1926
|
+
// mistaken for enforcing. Guarantees need the CI gate on a protected branch + a signed root.
|
|
1927
|
+
function reportProtection(p, config, verified) {
|
|
1928
|
+
const hasWorkflow = fs.existsSync(path.join(p.root, '.github', 'workflows', 'yaylayer.yml'));
|
|
1929
|
+
let pinned = false;
|
|
1930
|
+
try { if (hasWorkflow) pinned = /--root|trust[-\s]?root|ROOT/i.test(fs.readFileSync(path.join(p.root, '.github', 'workflows', 'yaylayer.yml'), 'utf8')); } catch (_) {}
|
|
1931
|
+
const phone = signMethodOf(config) === 'phone';
|
|
1932
|
+
const fnd = (config && config.foundation) || 'off';
|
|
1933
|
+
const enforcing = hasWorkflow && verified.signedRoster;
|
|
1934
|
+
const mk = (b) => (b ? U.c.green('✓') : U.c.red('✗'));
|
|
1935
|
+
console.log('\n ' + (enforcing ? U.c.green('Protection: ENFORCING') : U.c.yellow('⚠ Protection: ADVISORY ONLY'))
|
|
1936
|
+
+ U.c.dim(enforcing ? ' — confirm branch protection requires the "gate" check' : ' — local checks only; nothing enforces main until you set up the CI gate'));
|
|
1937
|
+
console.log(' ' + mk(hasWorkflow) + U.c.dim(' CI gate (yay gate) ') + mk(verified.signedRoster) + U.c.dim(' signed trust root ')
|
|
1938
|
+
+ (hasWorkflow ? (mk(pinned) + U.c.dim(' root pinned ')) : '')
|
|
1939
|
+
+ mk(phone) + U.c.dim(phone ? ' phone signing ' : ' phone signing (local keystore — key on disk) ')
|
|
1940
|
+
+ (fnd !== 'off' ? U.c.green('✓') : U.c.dim('○')) + U.c.dim(' foundation seal: ' + fnd));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function reportAttestStatus(p, config, manifest, verified) {
|
|
1944
|
+
const last = A.latestEntry(p, config);
|
|
1945
|
+
if (!last) { console.log(' ' + U.c.dim('no verifier attestation yet — mint one with ') + U.c.bold('yay attest') + U.c.dim(' (signs this verdict).')); return; }
|
|
1946
|
+
const cur = A.buildVerification(manifest, verified, { config, policy: verified.policy });
|
|
1947
|
+
const covered = cur.codeTreeHash === last.codeTreeHash;
|
|
1948
|
+
if (covered) console.log(' ' + U.c.dim('verifier attestation ' + U.c.green(last.hash.slice(0, 12)) + U.c.dim(` · ${last.passed ? 'PASS' : 'BLOCKED'} · capability ${last.capability}`)));
|
|
1949
|
+
else console.log(' ' + U.c.yellow('⚠ attestation stale') + U.c.dim(` — code changed since ${last.hash.slice(0, 12)}; re-mint with `) + U.c.bold('yay attest'));
|
|
1950
|
+
// Post-capability-bump nudge (P3): the verifier's capability advanced since the last attestation and
|
|
1951
|
+
// there's preserved history — a sweep can re-judge old approvals under the new checks (never rewrites them).
|
|
1952
|
+
if (last.capability && last.capability !== CAP.CAPABILITY) {
|
|
1953
|
+
let n = 0; try { n = D.listSnapshots(p).length; } catch (_) {}
|
|
1954
|
+
console.log(' ' + U.c.accent('↻ verifier capability advanced ' + last.capability + ' → ' + CAP.CAPABILITY)
|
|
1955
|
+
+ U.c.dim((n ? ' — re-judge ' + n + ' preserved state(s) with ' : ' — run ')) + U.c.bold('yay reverify --all'));
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// `yay attest` — mint a signed VERIFIER ATTESTATION over the current verified tree (P2). This is
|
|
1960
|
+
// the third cryptographic identity: the machine verifier signs its own verdict with its own key,
|
|
1961
|
+
// so "Green" becomes a portable, checkable object. Run at commit / after a green verify. Append-only
|
|
1962
|
+
// (chained to the previous attestation); the verifier PRIVATE key stays machine-side (gitignored),
|
|
1963
|
+
// the PUBLIC key is pinned in config (committed) so anyone can verify without being able to forge.
|
|
1964
|
+
function cmdAttest(flags, positional) {
|
|
1965
|
+
if (positional[0] === 'list' || positional[0] === 'verify' || flags.list) return cmdAttestList(flags, positional);
|
|
1966
|
+
const { p, config, lock } = loadState();
|
|
1967
|
+
if (!config) return fail('run `yay init` first');
|
|
1968
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
1969
|
+
if (!Object.keys(manifest.cells).length) return fail('no Cells to attest — write/adopt specs first.');
|
|
1970
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: !flags['no-mutate'], roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
1971
|
+
|
|
1972
|
+
// Capability self-check: the declared verifier version must match the live detector set, so an
|
|
1973
|
+
// attestation can never claim a capability the verifier doesn't actually have (drift = a detector
|
|
1974
|
+
// changed without a version bump). A shipped package never drifts; a modified one does.
|
|
1975
|
+
const capChk = CAP.assertCapability();
|
|
1976
|
+
if (capChk.drift && !flags['allow-capability-drift']) {
|
|
1977
|
+
return fail(`verifier capability DRIFT — the active detectors don't match the fingerprint registered for ${capChk.declared}.\n registered: ${String(capChk.expected).slice(0, 16)}…\n actual: ${capChk.actual.slice(0, 16)}…\n The verifier's provers/effect-nets/checks changed without a capability bump. Bump CAPABILITY in src/capability.js and set REGISTERED[<new>] = "${capChk.actual}" (or pass --allow-capability-drift to attest anyway).`);
|
|
1978
|
+
}
|
|
1979
|
+
// Refuse to sign a verdict that doesn't pass, unless explicitly told to record a failing one.
|
|
1980
|
+
if (!verified.passed && !flags.force) {
|
|
1981
|
+
return fail('gate is BLOCKED — refusing to attest a failing verdict. Fix the reds, or pass --force to record a failing attestation on purpose.');
|
|
1982
|
+
}
|
|
1983
|
+
const id = A.verifierIdentity(p, config); // creates the machine verifier key on first use
|
|
1984
|
+
const changed = A.pinVerifier(config, id);
|
|
1985
|
+
if (changed) U.writeJSON(p.config, config);
|
|
1986
|
+
const verObj = A.buildVerification(manifest, verified, { config, policy: verified.policy });
|
|
1987
|
+
const att = A.signAttestation(verObj, id);
|
|
1988
|
+
A.appendAttestation(p, config, att);
|
|
1989
|
+
console.log(U.c.green('✓ attestation minted') + U.c.dim(` — verifier ${id.fp}`));
|
|
1990
|
+
console.log(' ' + U.c.bold(att.hash.slice(0, 16)) + U.c.dim(` · ${att.result.passed ? 'PASS' : U.c.red('BLOCKED')} · ${att.result.counts.GREEN} green (${att.result.proven} proven) · capability ${att.capability}`));
|
|
1991
|
+
console.log(' ' + U.c.dim('code-tree ' + att.codeTreeHash.slice(0, 12) + ' · spec-set ' + att.specSetHash.slice(0, 12) + ' · ' + att.env.node + ' ' + att.env.platform));
|
|
1992
|
+
if (changed) console.log(' ' + U.c.dim('pinned this verifier as the project verifier of record → ') + U.c.bold('.yaylayer/config.json') + U.c.dim(' (commit it).'));
|
|
1993
|
+
console.log(' ' + U.c.dim('append-only ledger → ') + U.c.bold('.yaylayer/attest.json') + U.c.dim(' + full object in .yaylayer/attestations/ (commit both). Key stays in gitignored keys/.'));
|
|
1994
|
+
console.log(' ' + U.c.dim('verify anywhere (no upload, checks in-browser) → ') + U.c.bold('https://yaylayer.com/verify') + U.c.dim(' · registry cross-check: ') + U.c.bold('yay attest verify --registry'));
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Base URL of the public verifier-capability registry (yaylayer.com), overridable for testing.
|
|
1998
|
+
function registryBase(flags) { return (flags.registry && flags.registry !== true) ? String(flags.registry).replace(/\/$/, '') : 'https://yaylayer.com'; }
|
|
1999
|
+
|
|
2000
|
+
// `yay archive` (P4 — Durable mode + governance). Keeps an encrypted, sha256-anchored copy of the
|
|
2001
|
+
// SIGNED source so "what the code was when signed" survives git loss. Subcommands: enable/disable,
|
|
2002
|
+
// (default) archive the covered files, --forget (signed tombstone), --restore, --verify, --install.
|
|
2003
|
+
async function cmdArchive(flags, positional) {
|
|
2004
|
+
const { p, config, lock } = loadState();
|
|
2005
|
+
if (!config) return fail('run `yay init` first');
|
|
2006
|
+
const sub = positional[0];
|
|
2007
|
+
|
|
2008
|
+
if (sub === 'enable' || flags.enable) {
|
|
2009
|
+
config.provenance = { mode: 'durable' };
|
|
2010
|
+
if (flags.retention && flags.retention !== true) config.provenance.retention = String(flags.retention);
|
|
2011
|
+
U.writeJSON(p.config, config);
|
|
2012
|
+
ensureGitignored(p.root, '.yaylayer/keys/'); // (already ignored) — the archive KEY is never on disk
|
|
2013
|
+
console.log(U.c.green('✓ Durable mode ON') + U.c.dim(' — `yay archive` now keeps an encrypted, sha256-anchored copy of signed source.'));
|
|
2014
|
+
console.log(' ' + U.c.dim('the archive KEY is project-held (') + U.c.bold('$YAY_ARCHIVE_KEY') + U.c.dim(' or a passphrase) and NEVER stored by YayLayer. Archive with ') + U.c.bold('yay archive') + U.c.dim('.'));
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (sub === 'disable' || flags.disable) { config.provenance = { mode: 'standard' }; U.writeJSON(p.config, config); console.log(U.c.dim('Durable mode off — back to Standard (specs/attestations kept; bulk source via git).')); return; }
|
|
2018
|
+
|
|
2019
|
+
if (sub === 'install' || flags.install) {
|
|
2020
|
+
const hookDir = path.join(p.root, '.git', 'hooks');
|
|
2021
|
+
if (!fs.existsSync(hookDir)) return fail('no .git/hooks — is this a git repo? Run `git init` first.');
|
|
2022
|
+
const hook = path.join(hookDir, 'post-commit');
|
|
2023
|
+
const line = 'yay archive --quiet || true';
|
|
2024
|
+
let txt = ''; try { txt = fs.readFileSync(hook, 'utf8'); } catch (_) {}
|
|
2025
|
+
if (!txt) txt = '#!/bin/sh\n';
|
|
2026
|
+
if (txt.includes(line)) { console.log(U.c.dim('post-commit hook already archives — nothing to do.')); return; }
|
|
2027
|
+
fs.writeFileSync(hook, txt + (txt.endsWith('\n') ? '' : '\n') + '# YayLayer Durable archive\n' + line + '\n', { mode: 0o755 });
|
|
2028
|
+
console.log(U.c.green('✓ installed post-commit hook') + U.c.dim(' — each commit archives the signed source (needs $YAY_ARCHIVE_KEY in the environment).'));
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const arc = D.loadArchive(p) || { project: config.project, salt: C.randomNonce(), files: {}, tombstones: [], retention: (config.provenance && config.provenance.retention) || null };
|
|
2033
|
+
|
|
2034
|
+
if (sub === 'forget' || flags.forget) {
|
|
2035
|
+
const hash = (flags.forget && flags.forget !== true) ? flags.forget : positional[1];
|
|
2036
|
+
if (!hash) return fail('which blob? pass the content hash: `yay archive --forget <hash> --reason "…"`.');
|
|
2037
|
+
const reason = (flags.reason && flags.reason !== true) ? String(flags.reason) : null;
|
|
2038
|
+
if (!reason) return fail('a deletion needs a reason (recorded in the tombstone) — pass --reason "<why>".');
|
|
2039
|
+
const signer = localSignerName(p, config) || 'owner';
|
|
2040
|
+
const ts = D.tombstone(p, hash, reason, signer);
|
|
2041
|
+
arc.tombstones.push(ts);
|
|
2042
|
+
for (const f of Object.keys(arc.files)) if (arc.files[f].hash === hash) arc.files[f].status = 'tombstoned';
|
|
2043
|
+
D.saveArchive(p, arc);
|
|
2044
|
+
console.log(U.c.yellow('⧗ tombstoned') + U.c.dim(` ${String(hash).slice(0, 12)} — ciphertext deleted; a signed tombstone remains (honest erasure). Reason: ${reason}`));
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
if (sub === 'restore' || flags.restore) {
|
|
2049
|
+
const hash = (flags.restore && flags.restore !== true) ? flags.restore : positional[1];
|
|
2050
|
+
if (!hash) return fail('which blob? `yay archive --restore <hash>` (writes plaintext to stdout).');
|
|
2051
|
+
let key; try { key = D.resolveKey(await getArchivePass(flags), arc.salt); } catch (e) { return fail(e.message); }
|
|
2052
|
+
let pt; try { pt = D.getBlob(p, hash, key); } catch (e) { return fail(e.message); }
|
|
2053
|
+
if (pt == null) return fail('no such blob (or it was tombstoned).');
|
|
2054
|
+
process.stdout.write(pt);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if (sub === 'verify' || flags.verify) {
|
|
2059
|
+
let key; try { key = D.resolveKey(await getArchivePass(flags), arc.salt); } catch (e) { return fail(e.message); }
|
|
2060
|
+
const files = Object.keys(arc.files); let bad = 0, ok = 0, tomb = 0;
|
|
2061
|
+
for (const f of files) {
|
|
2062
|
+
const rec = arc.files[f];
|
|
2063
|
+
if (rec.status === 'tombstoned') { tomb++; continue; }
|
|
2064
|
+
try { const pt = D.getBlob(p, rec.hash, key); if (pt == null) { bad++; console.log(' ' + U.c.red('✗ ') + f + U.c.dim(' — blob missing')); } else ok++; }
|
|
2065
|
+
catch (e) { bad++; console.log(' ' + U.c.red('✗ ') + f + U.c.dim(' — ' + e.message)); }
|
|
2066
|
+
}
|
|
2067
|
+
console.log('\n ' + (bad ? U.c.red(`${bad} bad`) : U.c.green('all blobs decrypt + anchor')) + U.c.dim(` · ${ok} ok · ${tomb} tombstoned`));
|
|
2068
|
+
if (flags.strict) process.exit(bad ? 1 : 0);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// Default: archive the covered (signed) source files.
|
|
2073
|
+
const quiet = !!flags.quiet;
|
|
2074
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2075
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
2076
|
+
// Files that hold at least one signed Cell — "the source as signed."
|
|
2077
|
+
const coveredFiles = {};
|
|
2078
|
+
for (const id of Object.keys(verified.results)) { const r = verified.results[id]; const c = manifest.cells[id]; if (r.trust && r.trust.signed && c && c.file) (coveredFiles[c.file] = coveredFiles[c.file] || []).push(id); }
|
|
2079
|
+
const fileList = Object.keys(coveredFiles);
|
|
2080
|
+
if (!fileList.length) { if (!quiet) console.log(U.c.dim('nothing signed to archive yet — sign some Cells first.')); return; }
|
|
2081
|
+
|
|
2082
|
+
let key; try { key = D.resolveKey(await getArchivePass(flags), arc.salt); } catch (e) { return fail(e.message); }
|
|
2083
|
+
// Pre-archive SECRET SCAN — refuse to seal secrets into a permanent archive.
|
|
2084
|
+
const flagged = [];
|
|
2085
|
+
const contents = {};
|
|
2086
|
+
for (const f of fileList) {
|
|
2087
|
+
let txt = ''; try { txt = fs.readFileSync(path.join(p.root, f), 'utf8'); } catch (_) { continue; }
|
|
2088
|
+
contents[f] = txt;
|
|
2089
|
+
const hits = D.secretScan(txt);
|
|
2090
|
+
if (hits.length) flagged.push({ file: f, hits });
|
|
2091
|
+
}
|
|
2092
|
+
if (flagged.length && !flags['allow-secrets']) {
|
|
2093
|
+
console.log(U.c.red('✗ refusing to archive — possible secrets found (they would be preserved forever):'));
|
|
2094
|
+
for (const fl of flagged) for (const h of fl.hits) console.log(' ' + U.c.red('• ') + fl.file + ':' + h.line + U.c.dim(' — ' + h.kind));
|
|
2095
|
+
return fail('remove the secrets, or override with --allow-secrets (NOT recommended).');
|
|
2096
|
+
}
|
|
2097
|
+
let stored = 0, deduped = 0;
|
|
2098
|
+
for (const f of fileList) {
|
|
2099
|
+
if (!(f in contents)) continue;
|
|
2100
|
+
const res = D.putBlob(p, contents[f], key);
|
|
2101
|
+
arc.files[f] = { hash: res.hash, size: res.size, cells: coveredFiles[f], at: new Date().toISOString(), status: 'stored' };
|
|
2102
|
+
if (res.existed) deduped++; else stored++;
|
|
2103
|
+
}
|
|
2104
|
+
arc.retention = (config.provenance && config.provenance.retention) || arc.retention || null;
|
|
2105
|
+
D.saveArchive(p, arc);
|
|
2106
|
+
// Record a reconstructable SNAPSHOT — the file→blob-hash map for this exact state, tied to its
|
|
2107
|
+
// verification. Blobs are already deduped/preserved; this index is what lets a HISTORICAL tree be
|
|
2108
|
+
// rebuilt later (the reverify substrate). Best-effort: never fail an archive over the index.
|
|
2109
|
+
try {
|
|
2110
|
+
const snapFiles = {};
|
|
2111
|
+
for (const f of fileList) if (arc.files[f] && arc.files[f].status !== 'tombstoned') snapFiles[f] = arc.files[f].hash;
|
|
2112
|
+
const last = A.latestEntry(p, config);
|
|
2113
|
+
D.recordSnapshot(p, { at: new Date().toISOString(), codeTreeHash: A.codeTreeHashOf(manifest), attest: last ? last.hash : null, files: snapFiles });
|
|
2114
|
+
} catch (_) { /* index is additive; a failure here never blocks archiving */ }
|
|
2115
|
+
if (!quiet) {
|
|
2116
|
+
console.log(U.c.green(`✓ archived ${fileList.length} signed file(s)`) + U.c.dim(` — ${stored} new · ${deduped} unchanged${flagged.length ? ' · ' + U.c.yellow(flagged.length + ' had secrets (forced)') : ''}`));
|
|
2117
|
+
console.log(' ' + U.c.dim('encrypted (AES-256-GCM), sha256-anchored → ') + U.c.bold('.yaylayer/archive/') + U.c.dim(' + clear signed metadata in .yaylayer/archive.json (commit both). Key never stored.'));
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
function getArchivePass(flags) { return process.env.YAY_ARCHIVE_KEY ? Promise.resolve(null) : getPassphrase(flags, 'Enter the project archive passphrase (never stored)'); }
|
|
2121
|
+
function localSignerName(p, config) { try { const owners = (config && config.owners) || []; return owners.find((n) => fs.existsSync(path.join(p.keys, `${n}.keystore`))) || owners[0] || null; } catch (_) { return null; } }
|
|
2122
|
+
|
|
2123
|
+
// `yay capability` — print the verifier's derived capability descriptor + fingerprint, and whether
|
|
2124
|
+
// the declared version matches (drift = detectors changed without a version bump).
|
|
2125
|
+
function cmdCapability(flags) {
|
|
2126
|
+
const chk = CAP.assertCapability();
|
|
2127
|
+
const d = CAP.describeCapability();
|
|
2128
|
+
console.log(U.c.bold('Verifier capability ') + U.c.accent(chk.declared) + U.c.dim(' · fingerprint ' + chk.actual.slice(0, 16) + '…'));
|
|
2129
|
+
console.log(' ' + U.c.dim('provers: ') + d.provers.join(', '));
|
|
2130
|
+
console.log(' ' + U.c.dim('effect nets: ') + Object.keys(d.effectNets).join(', '));
|
|
2131
|
+
console.log(' ' + U.c.dim('checks: ') + d.checks.join(', '));
|
|
2132
|
+
console.log(' ' + U.c.dim('policy kinds: ') + d.policyKinds.join(', '));
|
|
2133
|
+
console.log('\n ' + (chk.drift
|
|
2134
|
+
? U.c.red('DRIFT') + U.c.dim(` — live fingerprint ≠ the one registered for ${chk.declared}. Bump CAPABILITY + register ${chk.actual.slice(0, 16)}…`)
|
|
2135
|
+
: U.c.green('✓ matches the registered fingerprint') + U.c.dim(' — the declared version honestly describes the verifier.')));
|
|
2136
|
+
if (flags.json) console.log('\n' + JSON.stringify({ ...chk, descriptor: d }, null, 2));
|
|
2137
|
+
if (flags.strict) process.exit(chk.drift ? 1 : 0);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// `yay reverify` (P4) — re-run verification at the CURRENT verifier capability and, if anything
|
|
2141
|
+
// changed (a capability bump, a verdict moving Yellow→Green as a prover lands, or code drift),
|
|
2142
|
+
// APPEND a new attestation that chains to the prior one. Never rewrites old attestations: a better
|
|
2143
|
+
// verifier's assessment is a NEW event beside the old, so history compounds instead of being edited.
|
|
2144
|
+
function cmdReverify(flags, positional) {
|
|
2145
|
+
positional = positional || [];
|
|
2146
|
+
// Posture management (P4): `yay reverify posture [off|guarded|strict]` — the grandfathering control.
|
|
2147
|
+
if (positional[0] === 'posture' || flags.posture) return cmdReverifyPosture(flags, positional);
|
|
2148
|
+
// Sweep mode (P3): replay ALL preserved history through today's verifier and diff each state against
|
|
2149
|
+
// its original attestation — Paper 3's "verifier upgrade report." Distinct from the default below,
|
|
2150
|
+
// which re-attests only the CURRENT tree. Needs Durable snapshots + (for reconstruction) the archive key.
|
|
2151
|
+
if (flags.all || flags.since || flags.eligible || flags.history) return cmdReverifyAll(flags);
|
|
2152
|
+
const { p, config, lock } = loadState();
|
|
2153
|
+
if (!config) return fail('run `yay init` first');
|
|
2154
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2155
|
+
if (!Object.keys(manifest.cells).length) return fail('no Cells to reverify.');
|
|
2156
|
+
const last = A.latestEntry(p, config);
|
|
2157
|
+
if (!last) return fail('no prior attestation to reverify against — mint the first with `yay attest`.');
|
|
2158
|
+
const prevAtt = A.loadAttestation(p, config, last.hash);
|
|
2159
|
+
if (!prevAtt) return fail('the latest attestation object is missing or fails its integrity check — cannot reverify against it.');
|
|
2160
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: !flags['no-mutate'], roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
2161
|
+
const verObj = A.buildVerification(manifest, verified, { config, policy: verified.policy });
|
|
2162
|
+
const diff = AS.reverifyDiff(prevAtt, verObj);
|
|
2163
|
+
|
|
2164
|
+
console.log(U.c.bold('Re-verification') + U.c.dim(` — prior ${last.hash.slice(0, 12)} (capability ${diff.fromCapability}) → now capability ${diff.toCapability}`));
|
|
2165
|
+
if (diff.capabilityChanged) console.log(' ' + U.c.accent(`verifier capability changed ${diff.fromCapability} → ${diff.toCapability}`));
|
|
2166
|
+
if (!diff.changes.length && verObj.codeTreeHash === prevAtt.codeTreeHash) {
|
|
2167
|
+
console.log(' ' + U.c.green('✓ no change') + U.c.dim(' — same verdicts, same code, same capability. Nothing appended (the prior attestation still stands).'));
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
for (const ch of diff.changes) {
|
|
2171
|
+
const arrow = ch.from + ' → ' + ch.to + (ch.toProven && !ch.fromProven ? ' (now machine-proven)' : '');
|
|
2172
|
+
console.log(' ' + (ch.to === 'GREEN' ? U.c.green('▲') : (ch.from === 'GREEN' ? U.c.red('▼') : U.c.yellow('•'))) + ' ' + ch.cell + U.c.dim(' · ' + arrow));
|
|
2173
|
+
}
|
|
2174
|
+
console.log(U.c.dim(` ${diff.improved} improved · ${diff.regressed} regressed · ${diff.changes.length} changed`));
|
|
2175
|
+
if (!verified.passed && !flags.force) return fail('gate is BLOCKED — refusing to append a failing re-verification. Pass --force to record it anyway.');
|
|
2176
|
+
const capChk = CAP.assertCapability();
|
|
2177
|
+
if (capChk.drift && !flags['allow-capability-drift']) return fail(`verifier capability DRIFT — detectors changed without a version bump (actual ${capChk.actual.slice(0, 16)}…). Bump CAPABILITY in src/capability.js + register it, or pass --allow-capability-drift.`);
|
|
2178
|
+
const id = A.verifierIdentity(p, config);
|
|
2179
|
+
A.pinVerifier(config, id) && U.writeJSON(p.config, config);
|
|
2180
|
+
const att = A.signAttestation(verObj, id);
|
|
2181
|
+
A.appendAttestation(p, config, att);
|
|
2182
|
+
console.log('\n' + U.c.green('✓ appended re-verification') + U.c.dim(` ${att.hash.slice(0, 16)} (chained to ${att.prev.slice(0, 12)}) — old attestations are untouched.`));
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function stripAnsi(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ''); }
|
|
2186
|
+
|
|
2187
|
+
// Render Paper 3's "verifier upgrade report" for a historical sweep. emit defaults to console.log;
|
|
2188
|
+
// pass a collector to also capture a plaintext copy (see `-o`).
|
|
2189
|
+
function renderUpgradeReport(report, flags, emit) {
|
|
2190
|
+
const say = emit || ((s) => console.log(s));
|
|
2191
|
+
const line = '─'.repeat(56);
|
|
2192
|
+
say(U.c.bold('Verifier upgrade report') + U.c.dim(' — capability ' + report.capability + ' · fingerprint ' + String(report.fingerprint || '').slice(0, 16) + '…'));
|
|
2193
|
+
say(' ' + U.c.dim(line));
|
|
2194
|
+
say(' ' + report.checked + ' historical state(s) checked'
|
|
2195
|
+
+ (report.deduped ? U.c.dim(' · ' + report.deduped + ' deduped') : '')
|
|
2196
|
+
+ (report.filtered ? U.c.dim(' · ' + report.filtered + ' filtered') : '')
|
|
2197
|
+
+ (report.errors ? U.c.red(' · ' + report.errors + ' error(s)') : ''));
|
|
2198
|
+
say(' ' + U.c.green(report.unchanged + ' unchanged') + U.c.dim(' · ') + U.c.green(report.improved + ' improved')
|
|
2199
|
+
+ U.c.dim(' · ') + (report.regressed ? U.c.red(report.regressed + ' regressed') : U.c.dim('0 regressed'))
|
|
2200
|
+
+ (report.noBaseline ? U.c.dim(' · ' + report.noBaseline + ' no-baseline') : ''));
|
|
2201
|
+
if (report.regressions.length) {
|
|
2202
|
+
say(' ' + U.c.dim(line));
|
|
2203
|
+
say(' ' + U.c.red('Regressions') + U.c.dim(" — old code today's verifier now judges more strictly (the original approvals stay historically valid):"));
|
|
2204
|
+
for (const r of report.regressions) say(' ' + U.c.red('▼ ') + r.cell
|
|
2205
|
+
+ U.c.dim(' · ' + r.from + ' → ' + r.to + (r.predicate ? ' (◈ undeclared input)' : '')
|
|
2206
|
+
+ ' · ' + (r.fromCapability || '?') + ' → ' + r.toCapability + (r.at ? ' · ' + String(r.at).slice(0, 10) : '')));
|
|
2207
|
+
}
|
|
2208
|
+
if (report.improvements.length) {
|
|
2209
|
+
if (!report.regressions.length) say(' ' + U.c.dim(line));
|
|
2210
|
+
for (const r of report.improvements) say(' ' + U.c.green('▲ ') + r.cell
|
|
2211
|
+
+ U.c.dim(' · ' + r.from + ' → ' + r.to + ' · ' + (r.fromCapability || '?') + ' → ' + r.toCapability + (r.at ? ' · ' + String(r.at).slice(0, 10) : '')));
|
|
2212
|
+
}
|
|
2213
|
+
if (report.errors) for (const s of report.states) if (s.error) say(' ' + U.c.red('✗ ') + (s.at ? String(s.at).slice(0, 10) + ' ' : '') + U.c.dim(s.error));
|
|
2214
|
+
say(' ' + U.c.dim(line));
|
|
2215
|
+
say(' ' + (report.regressed
|
|
2216
|
+
? U.c.yellow('⚠ ' + report.regressed + ' regression(s) — worth review; old approvals remain historically valid under their original verifier')
|
|
2217
|
+
: U.c.green('✓ no regressions — preserved history holds up under capability ' + report.capability)));
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// `yay reverify posture [off|guarded|strict]` (P4) — the grandfathering control. With no mode, prints
|
|
2221
|
+
// the current posture; with a mode, sets it. Stored in committed config (survives reroot), mirroring the
|
|
2222
|
+
// foundation seal posture. It gates on the EXISTENCE of signed reverification records, never on holding a
|
|
2223
|
+
// key — the keyless upgrade report is always available regardless of posture.
|
|
2224
|
+
function cmdReverifyPosture(flags, positional) {
|
|
2225
|
+
const { p, config } = loadState();
|
|
2226
|
+
if (!config) return fail('run `yay init` first');
|
|
2227
|
+
const raw = positional[1] || (flags.posture && flags.posture !== true ? flags.posture : '');
|
|
2228
|
+
const mode = String(raw || '').toLowerCase();
|
|
2229
|
+
if (!mode) {
|
|
2230
|
+
const cur = RV.reverifyPosture(config);
|
|
2231
|
+
console.log(U.c.bold('Reverification posture: ') + (cur === 'off' ? U.c.dim(cur) : U.c.accent(cur)));
|
|
2232
|
+
console.log(' ' + U.c.dim('off = preserved history is grandfathered (default) · guarded = warn when history predates the current verifier · strict = the gate blocks until history is re-verified under it.'));
|
|
2233
|
+
console.log(' ' + U.c.dim('set with ') + U.c.bold('yay reverify posture <off|guarded|strict>'));
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (!['off', 'guarded', 'strict'].includes(mode)) return fail('posture must be off, guarded, or strict.');
|
|
2237
|
+
config.reverification = mode; U.writeJSON(p.config, config);
|
|
2238
|
+
console.log(U.c.green('✓ reverification posture: ' + mode)
|
|
2239
|
+
+ U.c.dim(mode === 'strict' ? ' — the gate now BLOCKS until preserved history is re-verified under the current capability (`yay reverify --all --attest`).'
|
|
2240
|
+
: mode === 'guarded' ? ' — `yay verify` will WARN when preserved history predates the current capability.'
|
|
2241
|
+
: ' — grandfathered; nothing enforced.'));
|
|
2242
|
+
console.log(' ' + U.c.dim('stored in committed config (survives reroot), like the foundation seal posture.'));
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Reverification posture readout under `yay verify` (P4). Returns the gate result so the caller can fold
|
|
2246
|
+
// a strict, unsatisfied posture into the gate decision. Never needs a key — a deterministic check of
|
|
2247
|
+
// whether signed reverification records exist for preserved history under the current capability.
|
|
2248
|
+
function reportReverifyPosture(p, config, manifest, verified) {
|
|
2249
|
+
let g;
|
|
2250
|
+
try {
|
|
2251
|
+
// Per-Cell scoping (P4b): if the enforced (owner-signed) policy carries any `reverify: latest` rule,
|
|
2252
|
+
// only Cells it matches are subject; compute that Cell-id set from the live tree and pass it in.
|
|
2253
|
+
const policy = (verified && verified.policy) || null;
|
|
2254
|
+
let opts;
|
|
2255
|
+
if (policy && policyMod.hasReverifyScope(policy) && manifest) {
|
|
2256
|
+
const ids = new Set();
|
|
2257
|
+
for (const id of Object.keys(manifest.cells || {})) {
|
|
2258
|
+
const c = manifest.cells[id];
|
|
2259
|
+
if (policyMod.reverifyRequired(policy, { file: c.file, spec: c.spec, module: c.module })) ids.add(id);
|
|
2260
|
+
}
|
|
2261
|
+
opts = { scoped: true, scopedIds: ids };
|
|
2262
|
+
}
|
|
2263
|
+
g = RV.reverifyGate(p, config, opts);
|
|
2264
|
+
} catch (_) { return { posture: 'off', satisfied: true }; }
|
|
2265
|
+
if (g.posture === 'off') return g;
|
|
2266
|
+
const scopeNote = g.scoped ? U.c.dim(' [scoped]') : '';
|
|
2267
|
+
if (g.satisfied) { console.log(' ' + U.c.dim('↻ reverification posture (' + g.posture + ')' ) + scopeNote + U.c.dim(' — preserved history covered under capability ' + g.capability)); return g; }
|
|
2268
|
+
const n = g.pending.length;
|
|
2269
|
+
if (g.posture === 'strict') console.log(' ' + U.c.red('↻ REVERIFICATION REQUIRED') + U.c.dim(' — ' + n + ' preserved state(s) not re-verified under capability ' + g.capability + '; run ') + U.c.bold('yay reverify --all --attest') + U.c.dim(' (strict → gate blocked)'));
|
|
2270
|
+
else console.log(' ' + U.c.yellow('↻ reverification pending') + U.c.dim(' — ' + n + ' preserved state(s) predate capability ' + g.capability + '; run ') + U.c.bold('yay reverify --all') + U.c.dim(' to review'));
|
|
2271
|
+
return g;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// `yay reverify --all` (P3) — the HISTORICAL SWEEP. Reconstruct every preserved snapshot, re-run
|
|
2275
|
+
// today's verifier against it, and diff each Cell against its ORIGINAL signed verdict → Paper 3's
|
|
2276
|
+
// "verifier upgrade report" (what a better verifier now sees in old, already-approved code). Read-only
|
|
2277
|
+
// and KEYLESS by default — the re-verdict is a deterministic recomputation, so anyone can look without
|
|
2278
|
+
// a secret. `--attest` mints signed, append-only reverification records for the states that changed,
|
|
2279
|
+
// never rewriting the originals (a new immutable event beside the old). `--since <date>` / `--eligible`
|
|
2280
|
+
// filter the sweep; `-o <file>` saves a plaintext copy; `--json` prints the report as data.
|
|
2281
|
+
async function cmdReverifyAll(flags) {
|
|
2282
|
+
const { p, config } = loadState();
|
|
2283
|
+
if (!config) return fail('run `yay init` first');
|
|
2284
|
+
const snaps = D.listSnapshots(p);
|
|
2285
|
+
if (!snaps.length) return fail('no preserved snapshots to reverify — the sweep needs Durable history.\n Enable it (`yay archive enable`) and archive signed states (`yay archive`), then re-run. (Standard mode keeps history in git; reconstruct-from-git is not yet wired into the sweep.)');
|
|
2286
|
+
const arc = D.loadArchive(p);
|
|
2287
|
+
if (!arc) return fail('no archive found — Durable history is required for the sweep.');
|
|
2288
|
+
let key; try { key = D.resolveKey(await getArchivePass(flags), arc.salt); } catch (e) { return fail(e.message); }
|
|
2289
|
+
|
|
2290
|
+
const wantAttest = !!flags.attest;
|
|
2291
|
+
const report = RV.reverifySweep(p, key, config, {
|
|
2292
|
+
since: (flags.since && flags.since !== true) ? String(flags.since) : null,
|
|
2293
|
+
eligibleOnly: !!flags.eligible,
|
|
2294
|
+
keepVerObj: wantAttest,
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
if (flags.json) console.log(JSON.stringify(report, null, 2));
|
|
2298
|
+
else renderUpgradeReport(report, flags);
|
|
2299
|
+
|
|
2300
|
+
const outPath = (flags.o && flags.o !== true) ? String(flags.o) : ((flags.out && flags.out !== true) ? String(flags.out) : null);
|
|
2301
|
+
if (outPath) {
|
|
2302
|
+
const lines = [];
|
|
2303
|
+
renderUpgradeReport(report, flags, (s) => lines.push(stripAnsi(s)));
|
|
2304
|
+
fs.writeFileSync(outPath, lines.join('\n') + '\n');
|
|
2305
|
+
console.log(' ' + U.c.dim('→ wrote ' + outPath));
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
if (!wantAttest) {
|
|
2309
|
+
if (report.regressed || report.improved) console.log('\n ' + U.c.dim('record these as signed, append-only reverification attestations with ') + U.c.bold('yay reverify --all --attest') + U.c.dim(' (needs the verifier key).'));
|
|
2310
|
+
if (flags.strict) process.exit(report.regressed ? 1 : 0);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// ── --attest: mint signed reverification records for the changed states ──
|
|
2315
|
+
const id = A.verifierIdentity(p, config, { create: false });
|
|
2316
|
+
if (!id) return fail('--attest needs the verifier identity (the key that mints attestations) — run on the machine that holds .yaylayer/keys/verifier.json. The read-only report above needs no key.');
|
|
2317
|
+
const capChk = CAP.assertCapability();
|
|
2318
|
+
if (capChk.drift && !flags['allow-capability-drift']) return fail(`verifier capability DRIFT — detectors changed without a version bump (actual ${capChk.actual.slice(0, 16)}…). Bump CAPABILITY + register it, or pass --allow-capability-drift.`);
|
|
2319
|
+
|
|
2320
|
+
// Existing reverifications, to dedup — never re-mint an identical (original × capability × code) re-assessment.
|
|
2321
|
+
const led = A.loadLedger(p, config);
|
|
2322
|
+
const already = new Set();
|
|
2323
|
+
for (const e of led.entries) { const a = A.loadAttestation(p, config, e.hash); if (a && a.kind === 'reverification') already.add(a.reassesses + '|' + a.capability + '|' + a.codeTreeHash); }
|
|
2324
|
+
const baselineOf = (snap) => { if (!snap || !snap.attest) return null; const a = A.loadAttestation(p, config, snap.attest); return a ? { capability: a.capability || null, evidence: a.evidence || null } : null; };
|
|
2325
|
+
|
|
2326
|
+
let minted = 0, skipped = 0;
|
|
2327
|
+
for (const st of report.states) {
|
|
2328
|
+
if (!st.changed || !st.verObj) continue;
|
|
2329
|
+
const dedupKey = (st.attest || null) + '|' + report.capability + '|' + st.codeTreeHash;
|
|
2330
|
+
if (already.has(dedupKey)) { skipped++; continue; }
|
|
2331
|
+
const robj = RV.reverificationObj(st.verObj, { attest: st.attest, at: st.at }, baselineOf({ attest: st.attest }));
|
|
2332
|
+
const att = A.signAttestation(robj, id);
|
|
2333
|
+
A.appendAttestation(p, config, att);
|
|
2334
|
+
already.add(dedupKey);
|
|
2335
|
+
minted++;
|
|
2336
|
+
console.log(' ' + U.c.green('✓ reverification') + U.c.dim(' ' + att.hash.slice(0, 16) + ' — re-assesses ' + (st.attest ? String(st.attest).slice(0, 12) : '(no original)') + ' · ' + (st.fromCapability || '?') + ' → ' + report.capability));
|
|
2337
|
+
}
|
|
2338
|
+
A.pinVerifier(config, id) && U.writeJSON(p.config, config);
|
|
2339
|
+
console.log('\n ' + (minted ? U.c.green('✓ appended ' + minted + ' reverification record(s)') : U.c.dim('nothing new to record'))
|
|
2340
|
+
+ (skipped ? U.c.dim(' · ' + skipped + ' already recorded') : '')
|
|
2341
|
+
+ U.c.dim(' — originals untouched; chained into .yaylayer/attest.json (commit it).'));
|
|
2342
|
+
if (flags.strict) process.exit(report.regressed ? 1 : 0);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// `yay witness` (P4) — integrity witness: cross-check the append-only ledgers against each other and
|
|
2346
|
+
// the code on disk. Answers "is the record internally sound, and does it still describe reality?"
|
|
2347
|
+
function cmdWitness(flags) {
|
|
2348
|
+
const { p, config, lock } = loadState();
|
|
2349
|
+
if (!config) return fail('run `yay init` first');
|
|
2350
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2351
|
+
const w = AS.integrityWitness({
|
|
2352
|
+
ledger: A.loadLedger(p, config),
|
|
2353
|
+
loadAttestation: (h) => A.loadAttestation(p, config, h),
|
|
2354
|
+
currentCodeTreeHash: A.codeTreeHashOf(manifest),
|
|
2355
|
+
approvals: (lock && lock.approvals) || [],
|
|
2356
|
+
hasSpecObject: (h) => O.hasObject(p.root, h),
|
|
2357
|
+
});
|
|
2358
|
+
const c = w.checks;
|
|
2359
|
+
console.log(U.c.bold('Integrity witness'));
|
|
2360
|
+
console.log(' ' + (c.chainOk ? U.c.green('✓') : U.c.red('✗')) + U.c.dim(` attestation chain — ${c.attestationsValidated}/${c.attestations} validated`));
|
|
2361
|
+
console.log(' ' + (c.latestCovered ? U.c.green('✓') : (c.attestations ? U.c.yellow('⚠') : U.c.dim('–'))) + U.c.dim(` latest attestation covers the current code`));
|
|
2362
|
+
console.log(' ' + (c.specsMissing ? U.c.red('✗') : U.c.green('✓')) + U.c.dim(` spec archive — ${c.specsChecked - c.specsMissing}/${c.specsChecked} signed spec revisions archived`));
|
|
2363
|
+
for (const pb of w.problems) console.log(' ' + U.c.red('• ') + pb);
|
|
2364
|
+
console.log('\n ' + (w.ok ? U.c.green('WITNESS: SOUND') : U.c.red('WITNESS: PROBLEMS')) + U.c.dim(' (git/tree vs ledger vs spec-archive)'));
|
|
2365
|
+
if (flags.strict) process.exit(w.ok ? 0 : 1);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// `yay metrics` (P4) — earned-autonomy metrics from the rejection + delegation history.
|
|
2369
|
+
function cmdMetrics(flags) {
|
|
2370
|
+
const { p, config, lock } = loadState();
|
|
2371
|
+
if (!config) return fail('run `yay init` first');
|
|
2372
|
+
const m = AS.earnedAutonomy(loadRejections(p), (lock && lock.approvals) || []);
|
|
2373
|
+
console.log(U.c.bold('Earned-autonomy metrics') + U.c.dim(' — from delegation + ratification + rejection history'));
|
|
2374
|
+
console.log(' ' + U.c.dim(`delegated Cells: ${m.delegated} · ratified: ${m.ratified} · rejected: ${m.rejected}` + (m.ratifiedRate != null ? ` · ratified rate ${Math.round(m.ratifiedRate * 100)}%` : '')));
|
|
2375
|
+
const cats = Object.keys(m.byCategory);
|
|
2376
|
+
if (cats.length) { console.log(' ' + U.c.bold('rejections by category:')); for (const cat of cats.sort((a, b) => m.byCategory[b] - m.byCategory[a])) console.log(' ' + U.c.red(String(m.byCategory[cat])) + U.c.dim(' × ' + cat)); }
|
|
2377
|
+
else console.log(' ' + U.c.dim('no rejections recorded yet.'));
|
|
2378
|
+
for (const s of m.suggestions) console.log(' ' + U.c.yellow('→ ') + s);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// `yay attest list` / `yay attest verify` — read the ledger; re-check every stored attestation.
|
|
2382
|
+
async function cmdAttestList(flags, positional) {
|
|
2383
|
+
const { p, config } = loadState();
|
|
2384
|
+
if (!config) return fail('run `yay init` first');
|
|
2385
|
+
const sub = positional[0];
|
|
2386
|
+
const led = A.loadLedger(p, config);
|
|
2387
|
+
if (sub === 'verify') {
|
|
2388
|
+
const pub = A.verifierPub(p, config);
|
|
2389
|
+
if (!led.entries.length) { console.log(U.c.dim('no attestations to verify.')); return; }
|
|
2390
|
+
// Optional: cross-check each attestation's declared capability against the public registry at
|
|
2391
|
+
// yaylayer.com/verify, so a fingerprint that claims more than the canonical version is caught.
|
|
2392
|
+
let registry = null;
|
|
2393
|
+
if (flags.registry) {
|
|
2394
|
+
try { const r = await fetch(registryBase(flags) + '/capabilities.json'); registry = (await r.json()).capabilities || {}; }
|
|
2395
|
+
catch (e) { console.log(U.c.yellow(' ⚠ could not reach the capability registry') + U.c.dim(' — ' + ((e && e.message) || 'offline') + '; checking signatures only.')); }
|
|
2396
|
+
}
|
|
2397
|
+
let bad = 0, capBad = 0;
|
|
2398
|
+
for (const e of led.entries) {
|
|
2399
|
+
let raw = null; try { raw = JSON.parse(fs.readFileSync(A.attFile(p, e.hash), 'utf8')); } catch (_) { raw = null; }
|
|
2400
|
+
const chk = raw ? A.verifyAttestation(raw, pub) : { ok: false, reason: 'attestation object missing' };
|
|
2401
|
+
if (!chk.ok) bad++;
|
|
2402
|
+
let capNote = '';
|
|
2403
|
+
if (registry && raw) {
|
|
2404
|
+
const reg = registry[raw.capability];
|
|
2405
|
+
if (!reg) capNote = U.c.yellow(' · capability ' + raw.capability + ' not in registry');
|
|
2406
|
+
else if (raw.capabilityFingerprint && reg.fingerprint !== raw.capabilityFingerprint) { capBad++; capNote = U.c.red(' · ✗ capability fingerprint ≠ registry (over-claim!)'); }
|
|
2407
|
+
else if (raw.capabilityFingerprint) capNote = U.c.green(' · ✓ capability matches registry');
|
|
2408
|
+
}
|
|
2409
|
+
console.log(' ' + (chk.ok ? U.c.green('✓') : U.c.red('✗')) + ' ' + e.hash.slice(0, 16) + U.c.dim(` · ${e.at.slice(0, 16).replace('T', ' ')} · ${e.passed ? 'PASS' : 'BLOCKED'}`) + (chk.ok ? '' : U.c.red(' — ' + chk.reason)) + capNote);
|
|
2410
|
+
}
|
|
2411
|
+
console.log('\n ' + ((bad || capBad) ? U.c.red(`${bad} invalid${capBad ? ', ' + capBad + ' capability-mismatched' : ''}`) : U.c.green('all attestations valid' + (registry ? ' + capability matches the registry' : ''))) + U.c.dim(` · verifier of record ${(config.verifier && config.verifier.fp) || '(unpinned)'}`));
|
|
2412
|
+
if (flags.strict) process.exit((bad || capBad) ? 1 : 0);
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (!led.entries.length) { console.log(U.c.dim('no attestations yet — mint one with ') + U.c.bold('yay attest') + U.c.dim('.')); return; }
|
|
2416
|
+
console.log(U.c.bold(`${led.entries.length} attestation(s)`) + U.c.dim(` · capability ${led.capability} · verifier ${(config.verifier && config.verifier.fp) || '(unpinned)'}`));
|
|
2417
|
+
for (const e of led.entries.slice().reverse()) {
|
|
2418
|
+
console.log(' ' + U.c.bold(e.hash.slice(0, 16)) + U.c.dim(` · ${e.at.slice(0, 16).replace('T', ' ')} · ${e.passed ? U.c.green('PASS') : U.c.red('BLOCKED')} · code-tree ${e.codeTreeHash.slice(0, 12)} · cap ${e.capability}`));
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// Per-Cell "last changed" timeline, DERIVED (not stored in the spec):
|
|
2423
|
+
// 1. lock chain — the most recent signed approval that included the Cell
|
|
2424
|
+
// 2. git — last commit that touched the Cell's file
|
|
2425
|
+
// 3. filesystem mtime — last resort
|
|
2426
|
+
// P4 — per-Cell SEMANTIC TIMELINE: one chronological strip of a Cell's provenance events, stitched
|
|
2427
|
+
// from the append-only ledgers (approvals, attestations, rejections). Events: signed / delegated /
|
|
2428
|
+
// ratified (a human sign after a delegation) / attested (a verifier attestation whose evidence
|
|
2429
|
+
// includes the Cell) / rejected. Returns { cellId: [{at, kind, text}] } sorted oldest→newest.
|
|
2430
|
+
function buildCellTimelines(p, config, manifest, lock) {
|
|
2431
|
+
const out = Object.create(null);
|
|
2432
|
+
const push = (id, at, kind, text) => { if (!id) return; (out[id] = out[id] || []).push({ at: at || null, kind, text }); };
|
|
2433
|
+
const delegatedBefore = Object.create(null); // id → earliest delegation time (to detect ratification)
|
|
2434
|
+
for (const ap of (lock && lock.approvals) || []) {
|
|
2435
|
+
const ids = Object.keys(ap.items || {});
|
|
2436
|
+
for (const id of ids) {
|
|
2437
|
+
if (ap.autoApproved) { push(id, ap.at, 'delegated', `delegated under grant ${ap.grant || '?'}`); if (!delegatedBefore[id] || Date.parse(ap.at) < delegatedBefore[id]) delegatedBefore[id] = Date.parse(ap.at) || 0; }
|
|
2438
|
+
else {
|
|
2439
|
+
const ratifying = delegatedBefore[id] && (Date.parse(ap.at) || 0) >= delegatedBefore[id];
|
|
2440
|
+
push(id, ap.at, ratifying ? 'ratified' : 'signed', (ratifying ? 'ratified (signed for real) by ' : 'signed by ') + (ap.signer || '?'));
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
// rejections (P3)
|
|
2445
|
+
try { const rj = loadRejections(p); for (const e of ((rj && rj.events) || [])) { if (e.type !== 'reject') continue; for (const id of (e.cells || [])) push(id, e.at, 'rejected', `rejected (${e.category || 'other'}) by ${e.signer || e.by || '?'} — ${e.reason || ''}`); } } catch (_) {}
|
|
2446
|
+
// attestations (P2): load each once, index the cells it vouches for
|
|
2447
|
+
try {
|
|
2448
|
+
const led = A.loadLedger(p, config);
|
|
2449
|
+
for (const ent of (led.entries || [])) {
|
|
2450
|
+
const att = A.loadAttestation(p, config, ent.hash); if (!att || !att.evidence) continue;
|
|
2451
|
+
for (const id of Object.keys(att.evidence)) push(id, att.at, 'attested', `verifier attestation ${String(ent.hash).slice(0, 10)} — ${att.evidence[id].state}${att.evidence[id].proven ? ' (proven)' : ''}`);
|
|
2452
|
+
}
|
|
2453
|
+
} catch (_) {}
|
|
2454
|
+
for (const id of Object.keys(out)) out[id].sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0));
|
|
2455
|
+
return out;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// Timestamps are epoch ms; `source` says which signal won.
|
|
2459
|
+
function cellChanges(root, lock, cells) {
|
|
2460
|
+
const signedLast = Object.create(null);
|
|
2461
|
+
for (const ap of (lock && lock.approvals) || []) {
|
|
2462
|
+
const t = ap.at ? Date.parse(ap.at) : NaN;
|
|
2463
|
+
if (isNaN(t) || !ap.items) continue;
|
|
2464
|
+
for (const id of Object.keys(ap.items)) signedLast[id] = Math.max(signedLast[id] || 0, t);
|
|
2465
|
+
}
|
|
2466
|
+
// git per-file, cached: last commit (%cI of -1) and first commit (%cI of --reverse head).
|
|
2467
|
+
const gitCache = Object.create(null);
|
|
2468
|
+
const git = (rel, first) => {
|
|
2469
|
+
const key = (first ? 'A:' : 'Z:') + rel;
|
|
2470
|
+
if (key in gitCache) return gitCache[key];
|
|
2471
|
+
let t = 0;
|
|
2472
|
+
try {
|
|
2473
|
+
const a = first ? ['log', '--reverse', '--format=%cI', '--', rel] : ['log', '-1', '--format=%cI', '--', rel];
|
|
2474
|
+
const out = require('child_process')
|
|
2475
|
+
.execFileSync('git', ['-C', root, ...a], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
2476
|
+
.toString().split('\n')[0].trim();
|
|
2477
|
+
if (out) t = Date.parse(out) || 0;
|
|
2478
|
+
} catch (_) {}
|
|
2479
|
+
return (gitCache[key] = t);
|
|
2480
|
+
};
|
|
2481
|
+
const statMs = (rel, kind) => { try { const s = fs.statSync(path.join(root, rel)); return (kind === 'birth' ? s.birthtimeMs : s.mtimeMs) || 0; } catch (_) { return 0; } };
|
|
2482
|
+
|
|
2483
|
+
const out = [];
|
|
2484
|
+
const times = Object.create(null);
|
|
2485
|
+
for (const id of Object.keys(cells)) {
|
|
2486
|
+
const c = cells[id];
|
|
2487
|
+
if (c.contains && c.contains.length) continue; // leaf Cells (specs), not modules
|
|
2488
|
+
const s = signedLast[id] || 0;
|
|
2489
|
+
const g = git(c.file, false) || 0;
|
|
2490
|
+
let at = Math.max(s, g), source = s >= g ? 'signed' : 'code';
|
|
2491
|
+
if (!at) { at = statMs(c.file, 'mtime'); source = 'file'; }
|
|
2492
|
+
if (at) out.push({ id: 'u:' + id, at, source });
|
|
2493
|
+
times[id] = {
|
|
2494
|
+
createdCode: git(c.file, true) || statMs(c.file, 'birth') || null,
|
|
2495
|
+
changedCode: g || statMs(c.file, 'mtime') || null,
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
out.sort((a, b) => b.at - a.at);
|
|
2499
|
+
return { changes: out, times };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// Shared System-Plan (re)generation — used by `yay plan` and the dashboard button.
|
|
2503
|
+
// Costly (an LLM call), so it only runs when explicitly invoked. Returns a result
|
|
2504
|
+
// object rather than exiting, so the dashboard can report it.
|
|
2505
|
+
async function regeneratePlan(p, config, lock, flags) {
|
|
2506
|
+
const auth = resolvePlanAuth(config, flags);
|
|
2507
|
+
if (auth.error) return { ok: false, error: auth.error };
|
|
2508
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2509
|
+
if (!Object.keys(manifest.cells).length) return { ok: false, error: 'no Cells to plan yet — write/adopt some specs first.' };
|
|
2510
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
2511
|
+
const digest = plan.buildDigest(manifest, config.project);
|
|
2512
|
+
let result;
|
|
2513
|
+
try { result = await plan.synthesize(digest, auth); }
|
|
2514
|
+
catch (e) { return { ok: false, error: 'plan synthesis failed: ' + e.message }; }
|
|
2515
|
+
U.writeJSON(path.join(path.dirname(p.config), 'plan.json'), { provider: auth.provider, model: auth.model, at: new Date().toISOString(), digestHash: C.sha256(U.canonical(digest)), counts: verified.counts, ...result });
|
|
2516
|
+
return { ok: true, provider: auth.provider, model: auth.model, subsystems: (result.subsystems || []).length };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
async function cmdPlan(flags) {
|
|
2520
|
+
const { p, config, lock } = loadState();
|
|
2521
|
+
if (!config) return fail('run `yay init` first');
|
|
2522
|
+
const auth = resolvePlanAuth(config, flags);
|
|
2523
|
+
if (auth.error) return fail(auth.error + ' (each user brings their own key.)');
|
|
2524
|
+
console.log(U.c.dim(`synthesizing system plan with ${auth.provider}/${auth.model}${auth.baseUrl ? ' @ ' + auth.baseUrl : ''} …`));
|
|
2525
|
+
const r = await regeneratePlan(p, config, lock, flags);
|
|
2526
|
+
if (!r.ok) return fail(r.error);
|
|
2527
|
+
console.log(U.c.green('✓ system plan written → .yaylayer/plan.json') + U.c.dim(` (${r.subsystems} subsystem(s))`));
|
|
2528
|
+
console.log(' ' + U.c.dim('view it in ') + U.c.bold('yay map') + U.c.dim(' (System Plan tab) or the dashboard. Re-run `yay plan` to refresh.'));
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// Synthesize the plan into plan.json if enabled & specs changed. Never throws —
|
|
2532
|
+
// a missing key / network error just skips the plan and leaves the map intact.
|
|
2533
|
+
async function maybePlanForMap(p, config, manifest, verified, flags) {
|
|
2534
|
+
if (!(config && config.plan && config.plan.enabled) || flags['no-plan']) return;
|
|
2535
|
+
if (!Object.keys(manifest.cells).length) return;
|
|
2536
|
+
const planFile = path.join(path.dirname(p.config), 'plan.json');
|
|
2537
|
+
const auth = resolvePlanAuth(config, flags);
|
|
2538
|
+
if (auth.error) { console.log(U.c.yellow('• System Plan skipped: ') + U.c.dim(auth.error)); return; }
|
|
2539
|
+
const digest = plan.buildDigest(manifest, config.project);
|
|
2540
|
+
const hash = C.sha256(U.canonical(digest));
|
|
2541
|
+
const cached = U.readJSON(planFile, null);
|
|
2542
|
+
if (cached && cached.digestHash === hash && !flags.replan) { console.log(U.c.dim('• System Plan up to date (specs unchanged).')); return; }
|
|
2543
|
+
console.log(U.c.dim(`synthesizing System Plan with ${auth.provider}/${auth.model} …`));
|
|
2544
|
+
try {
|
|
2545
|
+
const result = await plan.synthesize(digest, auth);
|
|
2546
|
+
U.writeJSON(planFile, { provider: auth.provider, model: auth.model, at: new Date().toISOString(), digestHash: hash, counts: verified.counts, ...result });
|
|
2547
|
+
console.log(' ' + U.c.green('✓ System Plan updated'));
|
|
2548
|
+
} catch (e) { console.log(U.c.yellow('• System Plan skipped: ') + U.c.dim(e.message)); }
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// Build the full tabbed map HTML from the CURRENT repo state. Reused by `yay map`
|
|
2552
|
+
// (static export) and by `yay dashboard` (live). Reads the cached plan.json — it
|
|
2553
|
+
// never regenerates the plan (that costs an LLM call; only `yay map`/`yay plan` do).
|
|
2554
|
+
// The signed-Brief ledger (newest first), each seal re-verified so a tampered/forged one
|
|
2555
|
+
// can be flagged. Shared by the map's Briefs/Tags tabs and the `yay briefs` command.
|
|
2556
|
+
function collectBriefs(config, lock, drv) {
|
|
2557
|
+
const cfgSigners = (config && config.signers) || {};
|
|
2558
|
+
return (lock.approvals || []).filter((a) => a.brief && a.brief.text).map((a) => {
|
|
2559
|
+
const b = a.brief;
|
|
2560
|
+
const { signature, ...rest } = a;
|
|
2561
|
+
const trustedPubs = (drv.roster && drv.roster[a.signer]) || U.pubKeysOf(cfgSigners[a.signer]) || [];
|
|
2562
|
+
let valid = false;
|
|
2563
|
+
try { valid = !!signature && trustedPubs.some((pub) => pub && C.verify(U.canonical(rest), signature, pub)); } catch (_) { valid = false; }
|
|
2564
|
+
return { id: a.id, at: a.at, signer: a.signer, title: (b.title || ''), text: b.text, orderedBy: (b.orderedBy || ''), tags: b.tags || [], cells: Object.keys(a.items || {}), valid, auto: !!a.autoApproved, grant: a.grant || null };
|
|
2565
|
+
}).reverse();
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// `yay briefs` — the Brief ledger in the terminal. Newest-first by default; --by-tag groups
|
|
2569
|
+
// by tag (tag first, date second); --tag <name> filters to one tag.
|
|
2570
|
+
function cmdBriefs(flags) {
|
|
2571
|
+
const { p, config, lock } = loadState();
|
|
2572
|
+
if (!config) return fail('run `yay init` first');
|
|
2573
|
+
const drv = rosterMod.deriveRoster(loadRoster(p) || { events: [] });
|
|
2574
|
+
let briefs = collectBriefs(config, lock, drv);
|
|
2575
|
+
if (!briefs.length) { console.log(U.c.dim('no Briefs yet — sign a change-set with a Brief (`yay sign --brief "…"`).')); return; }
|
|
2576
|
+
const filterTag = (flags.tag && flags.tag !== true) ? String(flags.tag) : null;
|
|
2577
|
+
if (filterTag) briefs = briefs.filter((b) => (b.tags || []).some((t) => tagsMod.norm(t) === tagsMod.norm(filterTag)));
|
|
2578
|
+
const suffix = filterTag ? `, tag "${filterTag}"` : '';
|
|
2579
|
+
const line = (b) => {
|
|
2580
|
+
const when = String(b.at || '').slice(0, 10);
|
|
2581
|
+
console.log(' ' + (b.valid ? U.c.green('✓') : U.c.red('⚠')) + ' ' + U.c.accent(b.id) + U.c.dim(' · ' + when + (b.signer ? ' · ' + b.signer : '')));
|
|
2582
|
+
if (b.title) { console.log(' ' + U.c.bold(b.title)); console.log(' ' + U.c.dim(b.text)); }
|
|
2583
|
+
else console.log(' ' + b.text);
|
|
2584
|
+
if ((b.tags || []).length) console.log(' ' + b.tags.map((t) => U.c.dim('#') + U.c.bold(t)).join(' '));
|
|
2585
|
+
};
|
|
2586
|
+
if (flags['by-tag']) {
|
|
2587
|
+
const byTag = {};
|
|
2588
|
+
briefs.forEach((b) => ((b.tags && b.tags.length) ? b.tags : ['(untagged)']).forEach((t) => { (byTag[t] = byTag[t] || []).push(b); }));
|
|
2589
|
+
const names = Object.keys(byTag).sort((a, z) => (a === '(untagged)' ? 1 : z === '(untagged)' ? -1 : a.localeCompare(z)));
|
|
2590
|
+
console.log(U.c.bold('Briefs by tag') + U.c.dim(` (${briefs.length} total${suffix}):`));
|
|
2591
|
+
names.forEach((t) => { console.log('\n' + U.c.bold(t === '(untagged)' ? t : '#' + t) + U.c.dim(' · ' + byTag[t].length)); byTag[t].forEach(line); });
|
|
2592
|
+
} else {
|
|
2593
|
+
console.log(U.c.bold('Briefs') + U.c.dim(` — newest first (${briefs.length}${suffix}):`));
|
|
2594
|
+
briefs.forEach(line);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function buildMapHTML(p, config, lock, flags) {
|
|
2599
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2600
|
+
// Per-Cell spec diff vs last commit, so each Cell's detail can show what changed there.
|
|
2601
|
+
for (const id of Object.keys(manifest.cells)) { manifest.cells[id].diff = specDiffForCell(p.root, manifest.cells[id]); }
|
|
2602
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: !flags['no-mutate'], roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
2603
|
+
const { changes, times } = cellChanges(manifest.root, lock, manifest.cells);
|
|
2604
|
+
// P4: attach each Cell's semantic timeline (approvals/attestations/rejections) to its times entry.
|
|
2605
|
+
const timelines = buildCellTimelines(p, config, manifest, lock);
|
|
2606
|
+
for (const id of Object.keys(timelines)) { times[id] = times[id] || {}; times[id].timeline = timelines[id]; }
|
|
2607
|
+
const planDoc = flags['no-plan'] ? null : U.readJSON(path.join(path.dirname(p.config), 'plan.json'), null);
|
|
2608
|
+
// governance for the Signers tab: authoritative roster + device kind + approvals.
|
|
2609
|
+
const rlog = loadRoster(p);
|
|
2610
|
+
const drv = rosterMod.deriveRoster(rlog || { events: [] }, { root: trustRootPin(flags) });
|
|
2611
|
+
const cfgSigners = (config && config.signers) || {};
|
|
2612
|
+
const kindByPub = {};
|
|
2613
|
+
for (const n of Object.keys(cfgSigners)) { const e = cfgSigners[n]; if (Array.isArray(e)) e.forEach((k) => { if (k && k.pub) kindByPub[k.pub] = { kind: k.kind, addedAt: k.addedAt }; }); }
|
|
2614
|
+
const approvalsBy = {}; for (const a of (lock.approvals || [])) approvalsBy[a.signer] = (approvalsBy[a.signer] || 0) + 1;
|
|
2615
|
+
const rosterNames = Object.keys(drv.roster);
|
|
2616
|
+
const signers = (rosterNames.length ? rosterNames : Object.keys(cfgSigners)).sort().map((name) => {
|
|
2617
|
+
const pubs = drv.roster[name] || U.pubKeysOf(cfgSigners[name]);
|
|
2618
|
+
return { name, role: drv.roles[name] || 'signer', approvals: approvalsBy[name] || 0,
|
|
2619
|
+
keys: pubs.map((pub) => ({ fp: rosterMod.fingerprint(pub), kind: (kindByPub[pub] && kindByPub[pub].kind) || '', addedAt: (kindByPub[pub] && kindByPub[pub].addedAt) || null })) };
|
|
2620
|
+
});
|
|
2621
|
+
const gov = { signedRoster: !!(rlog && rlog.events && rlog.events.length), rootFp: drv.rootFp, problems: drv.problems, signers };
|
|
2622
|
+
// Briefs ledger (Standard §5): every approval that carries a brief, newest first.
|
|
2623
|
+
const briefs = collectBriefs(config, lock, drv);
|
|
2624
|
+
// Signing policy for the Policy tab: enforced (owner-signed, in the roster) vs the draft
|
|
2625
|
+
// file, plus the Cells currently violating it.
|
|
2626
|
+
const polViol = Object.keys(verified.results)
|
|
2627
|
+
.filter((id) => verified.results[id].policyOk === false)
|
|
2628
|
+
.map((id) => ({ id, note: ((verified.results[id].notes || []).find((n) => /^policy:/.test(n.text)) || {}).text || 'requires a specific signer' }));
|
|
2629
|
+
const policyInfo = {
|
|
2630
|
+
enforced: (drv.policy && drv.policy.rules) || [],
|
|
2631
|
+
draft: policyMod.loadPolicy(p).rules,
|
|
2632
|
+
violations: polViol,
|
|
2633
|
+
signers: signers.map((s) => s.name),
|
|
2634
|
+
signMethod: signMethodOf(config), // 'local' (keystore on this machine) or 'phone' (mobile) — tunes the Apply-button wording
|
|
2635
|
+
};
|
|
2636
|
+
const tagSets = tagsMod.TAG_SETS.map((s) => ({ id: s.id, name: s.name, desc: s.desc, tags: s.tags || [] }));
|
|
2637
|
+
// P2/P3 provenance surfaces for the dashboard: active grants (with envelopes) for the meaningful
|
|
2638
|
+
// ratify screen, the append-only rejection ledger, and the current verifier-attestation status.
|
|
2639
|
+
const extra = (function () {
|
|
2640
|
+
let grants = [];
|
|
2641
|
+
try {
|
|
2642
|
+
const glog = loadGrants(p);
|
|
2643
|
+
if (glog && glog.events) {
|
|
2644
|
+
const ownerPubs = Object.keys(drv.roles || {}).filter((n) => drv.roles[n] === 'owner').reduce((a, n) => a.concat(drv.roster[n] || []), []);
|
|
2645
|
+
const g = grantsMod.deriveGrants(glog, ownerPubs, lock.approvals);
|
|
2646
|
+
grants = Object.values(g).map((x) => ({ id: x.id, active: x.active, revoked: x.revoked, expired: x.expired, spent: x.spent, maxCount: x.maxCount, remaining: x.remaining, expiresAt: x.expiresAt, parent: x.parent || null, envelope: grantsMod.envelopeOf(x), chain: x.chain || null }));
|
|
2647
|
+
}
|
|
2648
|
+
} catch (_) {}
|
|
2649
|
+
let rejections = [];
|
|
2650
|
+
try { const rj = loadRejections(p); if (rj && rj.events) rejections = rj.events.filter((e) => e.type === 'reject').map((e) => ({ id: e.id, cells: e.cells || [], reason: e.reason, category: e.category, grants: e.grants || [], signer: e.signer || e.by, at: e.at })); } catch (_) {}
|
|
2651
|
+
let attest = null;
|
|
2652
|
+
try {
|
|
2653
|
+
const last = A.latestEntry(p, config);
|
|
2654
|
+
if (last) { const covered = last.codeTreeHash === A.codeTreeHashOf(manifest); attest = { hash: last.hash, at: last.at, passed: last.passed, capability: last.capability, covered, fp: (config.verifier && config.verifier.fp) || null }; }
|
|
2655
|
+
} catch (_) {}
|
|
2656
|
+
// Verifier capability descriptor for the Capabilities view (derived from the live provers/nets/checks).
|
|
2657
|
+
let capability = null;
|
|
2658
|
+
try { capability = { version: CAP.CAPABILITY, fingerprint: CAP.capabilityFingerprint(), descriptor: CAP.describeCapability(), pinned: (config.verifier && { fp: config.verifier.fp, capability: config.verifier.capability }) || null }; } catch (_) {}
|
|
2659
|
+
return { grants, rejections, attest, timelines, capability, demo: !!flags.demo };
|
|
2660
|
+
})();
|
|
2661
|
+
return { html: renderMap(manifest, verified, config && config.project, changes, times, planDoc, gov, briefs, tagsMod.loadTags(p), policyInfo, tagSets, batchConfig(config), extra), count: Object.keys(verified.results).length };
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// A cheap fingerprint of the state the map depends on, so the dashboard can tell
|
|
2665
|
+
// the page "something changed, reload" without rebuilding the whole map each poll.
|
|
2666
|
+
function stateVersion(p) {
|
|
2667
|
+
const parts = [];
|
|
2668
|
+
const dir = path.dirname(p.config);
|
|
2669
|
+
for (const f of ['config.json', 'lock.json', 'roster.json', 'plan.json', 'tags.json', 'policy.json']) {
|
|
2670
|
+
try { parts.push(f + ':' + fs.statSync(path.join(dir, f)).mtimeMs); } catch (_) { parts.push(f + ':0'); }
|
|
2671
|
+
}
|
|
2672
|
+
try { for (const f of U.walk(p.root)) { if (/\.(js|ts|jsx|tsx|py|css|html)$/.test(f) && !f.includes('.yaylayer')) parts.push(f + ':' + fs.statSync(f).mtimeMs); } } catch (_) {}
|
|
2673
|
+
return C.sha256(parts.join('|')).slice(0, 16);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// `yay adversary` — spec-only adversarial testing: an LLM sees ONLY each Cell's spec
|
|
2677
|
+
// (never the code) and writes probes that try to break it, run against the real code.
|
|
2678
|
+
async function cmdAdversary(flags) {
|
|
2679
|
+
const { p, config } = loadState();
|
|
2680
|
+
if (!config) return fail('run `yay init` first');
|
|
2681
|
+
const auth = resolvePlanAuth(config, flags);
|
|
2682
|
+
if (auth.error) return fail(auth.error + ' — the adversary needs an LLM (each user brings their own key).');
|
|
2683
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
2684
|
+
const only = (flags.cell && flags.cell !== true) ? String(flags.cell).split(',').map((s) => s.trim()) : null;
|
|
2685
|
+
const ids = Object.keys(manifest.cells).filter((id) => advEligible(manifest.cells[id]) && (!only || only.includes(id)));
|
|
2686
|
+
if (!ids.length) return fail('no eligible Cells — need a leaf Cell with a runnable unit and an ensures/out/throws promise.');
|
|
2687
|
+
console.log(U.c.dim(`spec-only adversary (${auth.provider}/${auth.model}) — writing probes from the specs ALONE, then running them against ${ids.length} Cell(s)…`));
|
|
2688
|
+
const res = await adversaryManifest(manifest, auth, { cells: only });
|
|
2689
|
+
let broke = 0, survived = 0, skipped = 0;
|
|
2690
|
+
for (const id of Object.keys(res).sort()) {
|
|
2691
|
+
const r = res[id], name = (manifest.cells[id].unitName || '');
|
|
2692
|
+
if (r.status === 'broke') { broke++; console.log(' ' + U.c.red('✗ ' + id) + U.c.dim(' ' + name) + ' — ' + U.c.red('BROKE: ') + r.counterexample); }
|
|
2693
|
+
else if (r.status === 'survived') { survived++; console.log(' ' + U.c.green('✓ ' + id) + U.c.dim(' ' + name + ' — survived adversarial probing')); }
|
|
2694
|
+
else { skipped++; console.log(' ' + U.c.yellow('– ' + id) + U.c.dim(' ' + name + ' — ' + (r.reason || r.status))); }
|
|
2695
|
+
}
|
|
2696
|
+
console.log('\n ' + (broke ? U.c.red(broke + ' broke') : U.c.green('0 broke')) + U.c.dim(` · ${survived} survived · ${skipped} skipped`));
|
|
2697
|
+
console.log(' ' + U.c.dim('probes were written from the spec only — a break is a real spec↔code violation, not a code echo.'));
|
|
2698
|
+
if (broke) process.exitCode = 1;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// `yay test` — run the project's own test suite (package.json "test" / config.test /
|
|
2702
|
+
// --test). Exit non-zero on failure so CI and the gate can use it.
|
|
2703
|
+
async function cmdTest(flags) {
|
|
2704
|
+
const { p, config } = loadState();
|
|
2705
|
+
if (!config) return fail('run `yay init` first');
|
|
2706
|
+
const cmd = resolveTestCmd(p.root, config, flags);
|
|
2707
|
+
if (!cmd) return fail('no test command — add a "test" script to package.json, set "test" in .yaylayer/config.json, or pass --test "…".');
|
|
2708
|
+
console.log(U.c.dim('running: ') + cmd);
|
|
2709
|
+
const r = await runTests(p.root, cmd);
|
|
2710
|
+
if (r.output) process.stdout.write(r.output.replace(/\n?$/, '\n'));
|
|
2711
|
+
console.log(r.ok ? U.c.green(`✓ tests passed`) + U.c.dim(` (${r.ms} ms)`) : U.c.red(`✗ tests failed — exit ${r.code}`));
|
|
2712
|
+
if (!r.ok) process.exitCode = 1;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// `yay dashboard` — a persistent live control panel (map that auto-refreshes).
|
|
2716
|
+
// Leave it running; re-reads the repo on every load so it always shows current state.
|
|
2717
|
+
// Read the shipped manual as plain text — the assistant's reference context.
|
|
2718
|
+
function readManualText() {
|
|
2719
|
+
try {
|
|
2720
|
+
const raw = fs.readFileSync(path.join(__dirname, '..', 'documentation', 'manual.html'), 'utf8');
|
|
2721
|
+
return raw.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
2722
|
+
.replace(/<[^>]+>/g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2723
|
+
.replace(/'|’|‘/g, "'").replace(/"/g, '"').replace(/ /g, ' ')
|
|
2724
|
+
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim().slice(0, 48000);
|
|
2725
|
+
} catch (_) { return ''; }
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// The repo assistant — answer questions about THIS project + the manual using the configured
|
|
2729
|
+
// (System Plan) LLM. Pre-populates the model with the live project STATE and the MANUAL.
|
|
2730
|
+
async function askRepo(question, flags) {
|
|
2731
|
+
const { p, config, lock } = loadState();
|
|
2732
|
+
if (!config) return { ok: false, error: 'run `yay init` first' };
|
|
2733
|
+
const q = String(question || '').trim();
|
|
2734
|
+
if (!q) return { ok: false, error: 'ask a question' };
|
|
2735
|
+
const auth = resolvePlanAuth(config, flags);
|
|
2736
|
+
if (auth.error) return { ok: false, error: auth.error };
|
|
2737
|
+
const manifest = buildManifest(p.root);
|
|
2738
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root), root: trustRootPin(flags) });
|
|
2739
|
+
const cells = Object.keys(verified.results).sort().map((id) => { const r = verified.results[id], m = manifest.cells[id] || {}; return { id, unit: (m.unitName || (m.spec && m.spec.unit) || r.name || ''), state: r.state, file: r.file, signer: (r.trust && r.trust.signer) || null, delegated: !!(r.trust && r.trust.auto), intent: (m.spec && m.spec.intent) || '', notes: (r.notes || []).map((n) => n.text) }; });
|
|
2740
|
+
const briefs = ((lock && lock.approvals) || []).map((a) => ({ id: a.id, at: a.at, signer: a.signer, delegated: !!a.autoApproved, title: (a.brief && (a.brief.title || a.brief.text)) || a.title || '', tags: (a.brief && a.brief.tags) || a.tags || [], cells: Object.keys(a.items || {}) }));
|
|
2741
|
+
const grants = (((loadGrants(p) || {}).events) || []).filter((e) => e.type === 'grant').map((e) => ({ id: e.id, envelope: e.envelope, expiresAt: e.expiresAt, maxCount: e.maxCount }));
|
|
2742
|
+
const rejections = (((loadRejections(p) || {}).events) || []).map((e) => ({ cells: e.cells, reason: e.reason, category: e.category, at: e.at, by: e.signer || e.by }));
|
|
2743
|
+
const digest = { project: config.project, gate: verified.passed ? 'PASS' : 'BLOCKED', counts: verified.counts, foundation: verified.foundation || null, cells, briefs, grants, rejections };
|
|
2744
|
+
const system = 'You are the YayLayer assistant for THIS project. You are given (1) the live PROJECT STATE as JSON — every Cell with its verifier state (GREEN/YELLOW/RED/UNSIGNED/PINK), signer, whether it is delegated, plus briefs, grants, rejections and the gate status — and (2) the YayLayer MANUAL. Answer the user\'s question accurately and concisely. For list/count questions use the PROJECT STATE (UNSIGNED = needs a human signature/approval; RED = code contradicts its spec; PINK = un-specced code; delegated = approved under a grant, awaiting human ratification). For "how does X work" questions use the MANUAL. Never invent Cells, states, or features; if the data does not contain the answer, say so plainly. Prefer short bullet lists. '
|
|
2745
|
+
+ 'When you reference a Cell, cite its id EXACTLY as it appears in the PROJECT STATE "id" field — copy it character-for-character, add no prefix and change nothing (ids may look like C-3f2a-7, C-041, or a PINK id in «guillemets» such as «module-level code»). Do NOT wrap a Cell id in backticks or code formatting — write the bare id so the dashboard can turn it into a clickable link. PINK Cells are un-specced code with no real Cell yet: refer to them by their exact «guillemet» id and their file, and note they need `yay adopt` to bring them under a spec.';
|
|
2746
|
+
const user = 'PROJECT STATE (JSON):\n' + JSON.stringify(digest) + '\n\nMANUAL (reference):\n' + readManualText() + '\n\nQUESTION: ' + q;
|
|
2747
|
+
try {
|
|
2748
|
+
const answer = await plan.chat(system, user, { provider: auth.provider, model: auth.model, apiKey: auth.apiKey, baseUrl: auth.baseUrl, maxTokens: 1400 });
|
|
2749
|
+
return { ok: true, answer: String(answer || '').trim(), provider: auth.provider, model: auth.model };
|
|
2750
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
async function cmdAsk(flags, positional) {
|
|
2754
|
+
const q = (positional || []).join(' ').trim() || (flags.q && flags.q !== true ? String(flags.q) : '');
|
|
2755
|
+
if (!q) return fail('usage: yay ask "your question about this repo or how YayLayer works"');
|
|
2756
|
+
process.stdout.write(U.c.dim('thinking…\n'));
|
|
2757
|
+
const r = await askRepo(q, flags);
|
|
2758
|
+
if (!r.ok) return fail(r.error);
|
|
2759
|
+
console.log('\n' + r.answer + '\n' + U.c.dim(`— ${r.provider}/${r.model}`));
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async function cmdDashboard(flags) {
|
|
2763
|
+
const { p, config } = loadState();
|
|
2764
|
+
if (!config) return fail('run `yay init` first');
|
|
2765
|
+
const port = (flags.port && flags.port !== true) ? Number(flags.port) : (Number(process.env.YAY_DASHBOARD_PORT) || 48757);
|
|
2766
|
+
const tls = tlsCert(p, flags);
|
|
2767
|
+
// The cert the phone should install to trust this dashboard: the mkcert ROOT CA
|
|
2768
|
+
// (so any yay project is trusted), or — for a self-signed run — the leaf cert itself.
|
|
2769
|
+
let caPem = null, caFilename = null;
|
|
2770
|
+
if (tls && tls.trusted && tls.caRoot) {
|
|
2771
|
+
try { caPem = fs.readFileSync(path.join(tls.caRoot, 'rootCA.pem'), 'utf8'); caFilename = 'yaylayer-rootCA.crt'; } catch (_) {}
|
|
2772
|
+
} else if (tls) { caPem = tls.cert; caFilename = 'yaylayer-cert.crt'; }
|
|
2773
|
+
|
|
2774
|
+
// Preview: run package.json scripts (dev server, build…) as background children, keyed by
|
|
2775
|
+
// name, so the dashboard can show a live link + output and stop them. Killed on Ctrl-C.
|
|
2776
|
+
const runningScripts = {};
|
|
2777
|
+
const readScripts = () => { try { const pk = JSON.parse(fs.readFileSync(path.join(p.root, 'package.json'), 'utf8')); return (pk && pk.scripts) || {}; } catch (_) { return {}; } };
|
|
2778
|
+
const scriptSnapshot = () => ({
|
|
2779
|
+
scripts: Object.entries(readScripts()).map(([name, cmd]) => ({ name, cmd })),
|
|
2780
|
+
running: Object.keys(runningScripts).map((name) => { const r = runningScripts[name]; return { name, url: r.url, alive: !!r.alive, startedAt: r.startedAt, output: r.output.join('').slice(-4000) }; }),
|
|
2781
|
+
});
|
|
2782
|
+
const runScript = (name) => {
|
|
2783
|
+
const sc = readScripts();
|
|
2784
|
+
if (!sc[name]) return { ok: false, error: 'no such script' };
|
|
2785
|
+
const ex = runningScripts[name];
|
|
2786
|
+
if (ex && ex.alive) return { ok: true, already: true };
|
|
2787
|
+
const child = require('child_process').spawn('npm', ['run', name], { cwd: p.root, detached: true, env: process.env });
|
|
2788
|
+
const rec = { child, output: [], url: null, alive: true, startedAt: Date.now() };
|
|
2789
|
+
runningScripts[name] = rec;
|
|
2790
|
+
const onData = (d) => {
|
|
2791
|
+
rec.output.push(d.toString());
|
|
2792
|
+
if (rec.output.length > 500) rec.output.splice(0, rec.output.length - 500);
|
|
2793
|
+
if (!rec.url) { const m = String(d).replace(/\x1b\[[0-9;]*m/g, '').match(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s'"]*/i); if (m) rec.url = m[0].replace(/0\.0\.0\.0/, 'localhost'); }
|
|
2794
|
+
};
|
|
2795
|
+
child.stdout.on('data', onData); child.stderr.on('data', onData);
|
|
2796
|
+
child.on('exit', () => { rec.alive = false; });
|
|
2797
|
+
child.on('error', (e) => { rec.alive = false; rec.output.push('\n[spawn error] ' + ((e && e.message) || e)); });
|
|
2798
|
+
return { ok: true };
|
|
2799
|
+
};
|
|
2800
|
+
const stopScript = (name) => {
|
|
2801
|
+
const r = runningScripts[name];
|
|
2802
|
+
if (!r || !r.alive) return { ok: true, already: true };
|
|
2803
|
+
try { process.kill(-r.child.pid, 'SIGTERM'); } catch (_) { try { r.child.kill('SIGTERM'); } catch (__) {} }
|
|
2804
|
+
r.alive = false; return { ok: true };
|
|
2805
|
+
};
|
|
2806
|
+
process.on('SIGINT', () => { Object.keys(runningScripts).forEach((n) => { if (runningScripts[n].alive) { try { process.kill(-runningScripts[n].child.pid, 'SIGTERM'); } catch (_) {} } }); process.exit(0); });
|
|
2807
|
+
|
|
2808
|
+
let s;
|
|
2809
|
+
try {
|
|
2810
|
+
s = await dashboardMod.startDashboard({
|
|
2811
|
+
project: config.project,
|
|
2812
|
+
buildMapHTML: () => { const st = loadState(); return buildMapHTML(st.p, st.config, st.lock, flags); },
|
|
2813
|
+
scripts: () => scriptSnapshot(),
|
|
2814
|
+
runScript: (name) => runScript(String(name)),
|
|
2815
|
+
stopScript: (name) => stopScript(String(name)),
|
|
2816
|
+
version: () => stateVersion(p),
|
|
2817
|
+
testInfo: () => { const st = loadState(); return { configured: !!resolveTestCmd(st.p.root, st.config, flags), cmd: resolveTestCmd(st.p.root, st.config, flags) }; },
|
|
2818
|
+
runTests: () => { const st = loadState(); return runTests(st.p.root, resolveTestCmd(st.p.root, st.config, flags)); },
|
|
2819
|
+
regenPlan: () => { const st = loadState(); return regeneratePlan(st.p, st.config, st.lock, flags); },
|
|
2820
|
+
diffs: () => {
|
|
2821
|
+
const st = loadState();
|
|
2822
|
+
const m = buildManifest(flags.dir || st.p.root);
|
|
2823
|
+
const out = [];
|
|
2824
|
+
for (const id of Object.keys(m.cells)) {
|
|
2825
|
+
const c = m.cells[id];
|
|
2826
|
+
const d = specDiffForCell(st.p.root, c);
|
|
2827
|
+
if (d && d.length) out.push({ id, unit: c.unitName || '', file: c.file, diff: d });
|
|
2828
|
+
}
|
|
2829
|
+
return out;
|
|
2830
|
+
},
|
|
2831
|
+
adversary: async () => {
|
|
2832
|
+
const st = loadState();
|
|
2833
|
+
const auth = resolvePlanAuth(st.config, flags);
|
|
2834
|
+
if (auth.error) return { error: auth.error };
|
|
2835
|
+
const m = buildManifest(flags.dir || st.p.root);
|
|
2836
|
+
const res = await adversaryManifest(m, auth);
|
|
2837
|
+
return { results: Object.keys(res).map((id) => ({ id, unit: (m.cells[id].unitName || ''), ...res[id] })) };
|
|
2838
|
+
},
|
|
2839
|
+
// Human-initiated sign from the dashboard: run `yay sign --brief "…"` as a child,
|
|
2840
|
+
// which discovers THIS dashboard (via .yaylayer/dashboard.json) and routes the request
|
|
2841
|
+
// to the phone. Reuses the whole sign path (brief, verify summary, seal-writing).
|
|
2842
|
+
signPending: (brief) => new Promise((resolve) => {
|
|
2843
|
+
const child = require('child_process').spawn(process.execPath, [process.argv[1], 'sign', '--brief', brief], { cwd: p.root });
|
|
2844
|
+
let out = '';
|
|
2845
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
2846
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
2847
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
2848
|
+
child.on('exit', (code) => resolve(code === 0
|
|
2849
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
2850
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('sign exited ' + code)) }));
|
|
2851
|
+
}),
|
|
2852
|
+
// Teammate enrollment from the dashboard: run the normal `yay enroll --phone`
|
|
2853
|
+
// as a child, which routes the OWNER-signed approval to the phone (or panel) and
|
|
2854
|
+
// writes the roster. All trust-critical logic is the existing enroll path.
|
|
2855
|
+
// Policy editor (draft): append / remove a rule in .yaylayer/policy.json. Editing the
|
|
2856
|
+
// draft enforces nothing on its own — `policyApply` owner-signs it into the roster.
|
|
2857
|
+
policyAddRule: (rule) => {
|
|
2858
|
+
const inertOk = rule && /^(note|yellow|block)$/i.test(String(rule.inert || ''));
|
|
2859
|
+
const ignoreOk = rule && /^source$/i.test(String(rule.ignore || ''));
|
|
2860
|
+
if (!rule || !rule.match || (!rule.match.path && !rule.match.tag && !rule.match.module) || (!rule.signer && !(rule.signers && rule.signers.length) && !inertOk && !ignoreOk)) {
|
|
2861
|
+
return { ok: false, error: 'a rule needs a matcher (path/tag/module) and a requirement — a signer, an inert level (note/yellow/block), or ignore:source' };
|
|
2862
|
+
}
|
|
2863
|
+
const rules = policyMod.loadPolicy(p).rules;
|
|
2864
|
+
const clean = { match: {}, };
|
|
2865
|
+
for (const k of ['path', 'tag', 'module']) if (rule.match[k]) clean.match[k] = String(rule.match[k]);
|
|
2866
|
+
if (rule.signer) clean.signer = String(rule.signer);
|
|
2867
|
+
if (inertOk) clean.inert = String(rule.inert).toLowerCase();
|
|
2868
|
+
if (ignoreOk) clean.ignore = 'source';
|
|
2869
|
+
rules.push(clean);
|
|
2870
|
+
U.writeJSON(policyMod.policyPath(p), { rules });
|
|
2871
|
+
return { ok: true, rules };
|
|
2872
|
+
},
|
|
2873
|
+
policyRemoveRule: (index) => {
|
|
2874
|
+
const rules = policyMod.loadPolicy(p).rules;
|
|
2875
|
+
if (!(index >= 0 && index < rules.length)) return { ok: false, error: 'no such rule' };
|
|
2876
|
+
rules.splice(index, 1);
|
|
2877
|
+
U.writeJSON(policyMod.policyPath(p), { rules });
|
|
2878
|
+
return { ok: true, rules };
|
|
2879
|
+
},
|
|
2880
|
+
// Owner-sign the draft policy into the roster — routes to the phone (reuses yay policy --set).
|
|
2881
|
+
policyApply: () => new Promise((resolve) => {
|
|
2882
|
+
const child = require('child_process').spawn(process.execPath, [process.argv[1], 'policy', '--set'], { cwd: p.root });
|
|
2883
|
+
let out = '';
|
|
2884
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
2885
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
2886
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
2887
|
+
child.on('exit', (code) => resolve(code === 0
|
|
2888
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
2889
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('policy --set exited ' + code)) }));
|
|
2890
|
+
}),
|
|
2891
|
+
// Autopilot: human-ratify the delegated Cells — routes to the phone (reuses yay ratify --sign).
|
|
2892
|
+
// `reviewed` is the bundle hash the dashboard rendered; the CLI refuses if the tree moved since.
|
|
2893
|
+
ratifyApply: (reviewed) => new Promise((resolve) => {
|
|
2894
|
+
const args = [process.argv[1], 'ratify', '--sign'];
|
|
2895
|
+
if (reviewed) { args.push('--reviewed', String(reviewed)); }
|
|
2896
|
+
const child = require('child_process').spawn(process.execPath, args, { cwd: p.root });
|
|
2897
|
+
let out = '';
|
|
2898
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
2899
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
2900
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
2901
|
+
child.on('exit', (code) => resolve(code === 0
|
|
2902
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
2903
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('ratify --sign exited ' + code)) }));
|
|
2904
|
+
}),
|
|
2905
|
+
// Briefs-tab history lens: reconstruct a Cell AS THE BRIEF SIGNED IT (from git, matched by
|
|
2906
|
+
// the stored specHash), plus its current version and a then→now spec diff. Read-only.
|
|
2907
|
+
cellAsOf: (briefId, cellId) => {
|
|
2908
|
+
try {
|
|
2909
|
+
const lk = U.readJSON(p.lock, { approvals: [] });
|
|
2910
|
+
const ap = (lk.approvals || []).find((a) => a.id === briefId);
|
|
2911
|
+
if (!ap || !ap.items || !(cellId in ap.items)) return { ok: false, error: 'this Brief does not cover that Cell' };
|
|
2912
|
+
const signedHash = ap.items[cellId];
|
|
2913
|
+
const S = require('../src/specdiff'), H = require('../src/history');
|
|
2914
|
+
const manifest = buildManifest(p.root);
|
|
2915
|
+
const cur = manifest.cells[cellId];
|
|
2916
|
+
const nowBlock = cur ? cur.specBlock : null, nowCode = cur ? (cur.unitBody || null) : null, nowFile = cur ? cur.file : null;
|
|
2917
|
+
const drifted = !cur || cur.specHash !== signedHash;
|
|
2918
|
+
let then;
|
|
2919
|
+
if (cur && !drifted) {
|
|
2920
|
+
then = { found: true, current: true, source: 'current', commit: null, at: ap.at, block: nowBlock, code: nowCode };
|
|
2921
|
+
} else {
|
|
2922
|
+
// P1: the spec AS SIGNED comes from the content-addressed archive first (git-independent,
|
|
2923
|
+
// never blanks); git is a fallback (legacy pre-archive Briefs) and the source of the
|
|
2924
|
+
// then-CODE + commit (code isn't archived until Durable mode, P4).
|
|
2925
|
+
const archBlock = O.getObject(p.root, signedHash);
|
|
2926
|
+
const g = nowFile ? H.cellAsSigned(p.root, nowFile, cellId, signedHash, 200) : { found: false };
|
|
2927
|
+
if (archBlock != null) then = { found: true, source: 'archive', block: archBlock, code: g.found ? g.code : null, commit: g.commit || null, at: g.at || ap.at };
|
|
2928
|
+
else if (g.found) then = { ...g, source: 'git' };
|
|
2929
|
+
else then = { found: false, reason: cur ? 'the signed version wasn’t archived and could not be reconstructed from git' : 'this Cell is no longer in the codebase' };
|
|
2930
|
+
}
|
|
2931
|
+
let diff = null, nowState = null;
|
|
2932
|
+
if (cur) { try { const v = verifyManifest(manifest, lk, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p) }); nowState = (v.results[cellId] || {}).state || null; } catch (_) {} }
|
|
2933
|
+
if (then.found && cur) diff = S.lineDiff(S.normalizedToSpecLines(then.block), S.normalizedToSpecLines(nowBlock));
|
|
2934
|
+
return { ok: true, brief: briefId, cell: cellId, signedHash, at: ap.at, signer: ap.signer || null, drifted: !!drifted,
|
|
2935
|
+
then, current: cur ? { block: nowBlock, code: nowCode, file: nowFile, line: cur.line || 0, state: nowState } : null, diff };
|
|
2936
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
2937
|
+
},
|
|
2938
|
+
// Live tag-pool editing from the dashboard (add / remove / rename / describe). Renaming
|
|
2939
|
+
// a tag already used in a signed Brief is refused — it would split the history.
|
|
2940
|
+
tagsEdit: (op) => {
|
|
2941
|
+
const st = loadState();
|
|
2942
|
+
const cur = tagsMod.loadTags(st.p) || { project: st.config.project, set: 'custom', tags: [], descriptions: {} };
|
|
2943
|
+
cur.descriptions = cur.descriptions || {};
|
|
2944
|
+
const norm = tagsMod.norm;
|
|
2945
|
+
const uses = {}; for (const a of (st.lock.approvals || [])) for (const t of ((a.brief && a.brief.tags) || [])) uses[norm(t)] = (uses[norm(t)] || 0) + 1;
|
|
2946
|
+
const action = op && op.action;
|
|
2947
|
+
if (action === 'set') { // pick a starter set (or 'custom') as the WHOLE pool
|
|
2948
|
+
// Only allowed until the first tagged Brief is signed — a wholesale swap would
|
|
2949
|
+
// orphan used tags into "Retired" and split the history (same rule as rename).
|
|
2950
|
+
if (Object.keys(uses).length) return { ok: false, error: 'tags are already in use in signed Briefs — switching the whole set would split the history. Add or remove individual tags instead.' };
|
|
2951
|
+
if (op.set === 'custom') { tagsMod.saveTags(st.p, { project: st.config.project, set: 'custom', tags: tagsMod.CUSTOM_SEED.slice(), descriptions: {} }); return { ok: true }; }
|
|
2952
|
+
const set = tagsMod.setById(op.set);
|
|
2953
|
+
if (!set) return { ok: false, error: 'unknown set' };
|
|
2954
|
+
tagsMod.saveTags(st.p, { project: st.config.project, set: set.id, tags: set.tags.slice(), descriptions: {} });
|
|
2955
|
+
return { ok: true };
|
|
2956
|
+
}
|
|
2957
|
+
if (action === 'add') {
|
|
2958
|
+
const label = String((op.label != null ? op.label : '')).trim();
|
|
2959
|
+
if (!label) return { ok: false, error: 'empty tag' };
|
|
2960
|
+
if (!tagsMod.isKnown(cur.tags, label)) cur.tags.push(label);
|
|
2961
|
+
} else if (action === 'remove') {
|
|
2962
|
+
cur.tags = cur.tags.filter((t) => norm(t) !== norm(op.label));
|
|
2963
|
+
for (const k of Object.keys(cur.descriptions)) if (norm(k) === norm(op.label)) delete cur.descriptions[k];
|
|
2964
|
+
} else if (action === 'rename') {
|
|
2965
|
+
const i = cur.tags.findIndex((t) => norm(t) === norm(op.from));
|
|
2966
|
+
if (i < 0) return { ok: false, error: 'no such tag' };
|
|
2967
|
+
if (uses[norm(op.from)]) return { ok: false, error: `"${cur.tags[i]}" is used in ${uses[norm(op.from)]} signed Brief(s) — renaming would split the history. Add a new tag, or remove this one.` };
|
|
2968
|
+
const to = String((op.to != null ? op.to : '')).trim();
|
|
2969
|
+
if (!to) return { ok: false, error: 'empty new label' };
|
|
2970
|
+
const old = cur.tags[i]; cur.tags[i] = to;
|
|
2971
|
+
if (cur.descriptions[old] !== undefined) { cur.descriptions[to] = cur.descriptions[old]; delete cur.descriptions[old]; }
|
|
2972
|
+
} else if (action === 'desc') {
|
|
2973
|
+
const canon = cur.tags.find((t) => norm(t) === norm(op.label));
|
|
2974
|
+
if (!canon) return { ok: false, error: 'no such tag' };
|
|
2975
|
+
const text = String((op.text != null ? op.text : '')).trim();
|
|
2976
|
+
if (text) cur.descriptions[canon] = text; else delete cur.descriptions[canon];
|
|
2977
|
+
} else return { ok: false, error: 'unknown action' };
|
|
2978
|
+
tagsMod.saveTags(st.p, cur);
|
|
2979
|
+
return { ok: true };
|
|
2980
|
+
},
|
|
2981
|
+
// Batch settings (how many small changes group into one Brief).
|
|
2982
|
+
batchSet: (op) => {
|
|
2983
|
+
const st = loadState();
|
|
2984
|
+
const cur = batchConfig(st.config);
|
|
2985
|
+
const enabled = (op && typeof op.enabled === 'boolean') ? op.enabled : cur.enabled;
|
|
2986
|
+
let barrier = (op && op.barrier != null) ? parseInt(op.barrier, 10) : cur.barrier;
|
|
2987
|
+
if (!(barrier >= 1 && barrier <= 100)) barrier = cur.barrier;
|
|
2988
|
+
st.config.batch = { enabled, barrier };
|
|
2989
|
+
U.writeJSON(st.p.config, st.config);
|
|
2990
|
+
return { ok: true, batch: st.config.batch };
|
|
2991
|
+
},
|
|
2992
|
+
// Queue a plain human request the AI will turn into a Brief + Cells (see `yay requests`).
|
|
2993
|
+
addRequest: (text) => {
|
|
2994
|
+
const rp = requestsPath(p);
|
|
2995
|
+
const log = U.readJSON(rp, null) || { project: config.project, requests: [] };
|
|
2996
|
+
log.requests = log.requests || [];
|
|
2997
|
+
const id = 'REQ-' + String(log.requests.length + 1).padStart(3, '0');
|
|
2998
|
+
log.requests.push({ id, text: String(text), status: 'pending', at: new Date().toISOString() });
|
|
2999
|
+
U.writeJSON(rp, log);
|
|
3000
|
+
ensureGitignored(p.root, '.yaylayer/requests.json');
|
|
3001
|
+
return { ok: true, id };
|
|
3002
|
+
},
|
|
3003
|
+
enroll: ({ name, pubkey, role }) => new Promise((resolve) => {
|
|
3004
|
+
const args = ['enroll', '--name', String(name), '--pubkey', String(pubkey), '--phone'];
|
|
3005
|
+
if (role === 'owner') args.push('--role', 'owner');
|
|
3006
|
+
const child = require('child_process').spawn(process.execPath, [process.argv[1], ...args], { cwd: p.root });
|
|
3007
|
+
let out = '';
|
|
3008
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
3009
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
3010
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
3011
|
+
child.on('exit', (code) => resolve(code === 0
|
|
3012
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
3013
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('enroll exited ' + code)) }));
|
|
3014
|
+
}),
|
|
3015
|
+
// Issue an Autopilot grant (owner-signed) from the dashboard — spawns `yay grant … --phone`,
|
|
3016
|
+
// routing the owner approval to the phone (same pattern as enroll). Only a human can issue it.
|
|
3017
|
+
ask: (q) => askRepo(q, flags),
|
|
3018
|
+
protect: () => new Promise((resolve) => {
|
|
3019
|
+
const child = require('child_process').spawn(process.execPath, [process.argv[1], 'protect', '--phone'], { cwd: p.root });
|
|
3020
|
+
let out = '';
|
|
3021
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
3022
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
3023
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
3024
|
+
child.on('exit', (code) => resolve(code === 0
|
|
3025
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
3026
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('protect exited ' + code)) }));
|
|
3027
|
+
}),
|
|
3028
|
+
grant: (o) => new Promise((resolve) => {
|
|
3029
|
+
o = o || {};
|
|
3030
|
+
const args = ['grant', '--for', String(o.dur || '2h'), '--count', String(parseInt(o.count, 10) || 20), '--phone'];
|
|
3031
|
+
if (o.allow) args.push('--allow', String(o.allow));
|
|
3032
|
+
if (o.deny) args.push('--deny', String(o.deny));
|
|
3033
|
+
if (o.allowTags) args.push('--allow-tag', String(o.allowTags));
|
|
3034
|
+
if (o.denyTags) args.push('--deny-tag', String(o.denyTags));
|
|
3035
|
+
if (o.maxRisk) args.push('--max-risk', String(o.maxRisk));
|
|
3036
|
+
if (o.childGrants) args.push('--child-grants');
|
|
3037
|
+
if (o.noGuard) args.push('--no-guard');
|
|
3038
|
+
const child = require('child_process').spawn(process.execPath, [process.argv[1], ...args], { cwd: p.root });
|
|
3039
|
+
let out = '';
|
|
3040
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
3041
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
3042
|
+
child.on('error', (e) => resolve({ ok: false, error: String((e && e.message) || e) }));
|
|
3043
|
+
child.on('exit', (code) => resolve(code === 0
|
|
3044
|
+
? { ok: true, output: out.replace(/\x1b\[[0-9;]*m/g, '').trim() }
|
|
3045
|
+
: { ok: false, error: (out.replace(/\x1b\[[0-9;]*m/g, '').trim() || ('grant exited ' + code)) }));
|
|
3046
|
+
}),
|
|
3047
|
+
}, { port, tls, caPem, caFilename });
|
|
3048
|
+
} catch (e) {
|
|
3049
|
+
if (e && e.code === 'EADDRINUSE') return fail(`port ${port} is already in use — a dashboard may already be running (open http://localhost:${port}), or pass --port.`);
|
|
3050
|
+
return fail(e.message || String(e));
|
|
3051
|
+
}
|
|
3052
|
+
// Register so the CLI (yay sign/pair/…) can find this dashboard and route through it.
|
|
3053
|
+
const regPath = path.join(path.dirname(p.config), 'dashboard.json');
|
|
3054
|
+
U.writeJSON(regPath, { scheme: tls ? 'https' : 'http', port: s.port, phoneUrl: s.url + '/phone', startedAt: new Date().toISOString() });
|
|
3055
|
+
ensureGitignored(p.root, '.yaylayer/dashboard.json');
|
|
3056
|
+
const cleanup = () => { try { fs.unlinkSync(regPath); } catch (_) {} };
|
|
3057
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
3058
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
3059
|
+
process.on('exit', cleanup);
|
|
3060
|
+
|
|
3061
|
+
const transport = phoneTransport(config, flags);
|
|
3062
|
+
console.log('\n' + U.c.green('✓ yay dashboard is live') + U.c.dim(' — keep this running; scan ONCE, then approvals appear on your phone automatically.'));
|
|
3063
|
+
console.log(' ' + U.c.bold('this computer → ') + U.c.accent(s.local) + U.c.dim(' (the live map + buttons)'));
|
|
3064
|
+
if (transport === 'relay') {
|
|
3065
|
+
// This project signs over the relay, so the phone page is the RELAY page (not the
|
|
3066
|
+
// dashboard's LAN /phone, which only sees LAN-routed requests). Show that instead.
|
|
3067
|
+
const rs = ensureRelaySession(p, flags);
|
|
3068
|
+
console.log(' ' + U.c.bold('phone (relay) → ') + U.c.accent(rs.url) + U.c.dim(' (this project signs via relay.yaylayer.com — approve here, from any network)'));
|
|
3069
|
+
printQR(rs.url);
|
|
3070
|
+
console.log(' ' + U.c.dim('scan ONCE; ') + U.c.bold('yay sign') + U.c.dim(' and ') + U.c.bold('yay invite') + U.c.dim(' route to this relay page automatically. Ctrl-C to stop.'));
|
|
3071
|
+
} else {
|
|
3072
|
+
console.log(' ' + U.c.bold('phone → ') + U.c.accent(s.url + '/phone') + U.c.dim(' (scan the QR below — same Wi-Fi as this computer)'));
|
|
3073
|
+
printQR(s.url + '/phone');
|
|
3074
|
+
if (tls && tls.trusted) {
|
|
3075
|
+
console.log(U.c.dim(' https: ') + U.c.green('locally-trusted cert (mkcert)') + U.c.dim(' — no warning on this computer.'));
|
|
3076
|
+
if (caPem) console.log(U.c.dim(' phone warning-free (one-time): open ') + U.c.accent(s.url + '/trust') + U.c.dim(' on the phone → install + trust the certificate (guided).'));
|
|
3077
|
+
} else if (tls) {
|
|
3078
|
+
console.log(U.c.dim(' https: self-signed (encrypted) — tap through the one-time "not private" warning on the phone (Advanced → visit).'));
|
|
3079
|
+
if (caPem) console.log(U.c.dim(' or make it warning-free: open ') + U.c.accent(s.url + '/trust') + U.c.dim(' on the phone; better still ') + U.c.accent('brew install mkcert && mkcert -install') + U.c.dim(' then restart.'));
|
|
3080
|
+
}
|
|
3081
|
+
console.log(' ' + U.c.dim('`yay sign` now routes here — the request pops up on your phone. Ctrl-C to stop.'));
|
|
3082
|
+
}
|
|
3083
|
+
const tc = resolveTestCmd(p.root, config, flags);
|
|
3084
|
+
console.log(' ' + U.c.dim('buttons (this computer): ') + U.c.bold('▷ Preview') + U.c.dim(' (run a package.json script) · ') + U.c.bold('▶ Run tests') + U.c.dim(tc ? ` (${tc})` : ' (none)') + U.c.dim(' · ') + U.c.bold('⚔ Adversary') + U.c.dim(' · ') + U.c.bold('⟲ System Plan') + U.c.dim(' · ') + U.c.bold('≷ Changes'));
|
|
3085
|
+
if (flags.open) { try { require('child_process').exec((process.platform === 'darwin' ? 'open ' : 'xdg-open ') + JSON.stringify(s.local)); } catch (_) {} }
|
|
3086
|
+
await new Promise(() => {}); // run until Ctrl-C
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
async function cmdMap(flags) {
|
|
3090
|
+
const { p, config, lock } = loadState();
|
|
3091
|
+
const manifest = buildManifest(flags.dir || p.root);
|
|
3092
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: !flags['no-mutate'], roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
3093
|
+
await maybePlanForMap(p, config, manifest, verified, flags);
|
|
3094
|
+
const { html, count } = buildMapHTML(p, config, lock, flags);
|
|
3095
|
+
const out = (flags.o && flags.o !== true) ? flags.o : (flags.out && flags.out !== true ? flags.out : 'yay-layer-map.html');
|
|
3096
|
+
fs.writeFileSync(out, html);
|
|
3097
|
+
console.log(U.c.green('✓ map written → ') + out + U.c.dim(` (${count} items)`));
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// `yay id` — this working copy's Cell-id shard (creates it if absent) + the next id it would mint.
|
|
3101
|
+
function cmdId(flags) {
|
|
3102
|
+
const { p, config } = loadState();
|
|
3103
|
+
if (!config) return fail('run `yay init` first');
|
|
3104
|
+
const shard = ensureLocalShard(p);
|
|
3105
|
+
const taken = new Set(ledgerCellIds(p));
|
|
3106
|
+
try { const man = buildManifest(p.root); for (const id of Object.keys(man.cells)) taken.add(id); } catch (_) {}
|
|
3107
|
+
const next = IDS.nextCellId(shard, taken);
|
|
3108
|
+
console.log(U.c.bold('Cell-id shard: ') + U.c.accent('C-' + shard + '-…') + U.c.dim(' (unique to this working copy — in gitignored local.json, never committed)'));
|
|
3109
|
+
console.log(' ' + U.c.dim('next new id here → ') + U.c.bold(next));
|
|
3110
|
+
console.log(' ' + U.c.dim('Other clones mint under their own shard, so ids never collide when branches merge.'));
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
// `yay merge` — run this right after a `git merge`/`git pull`. Re-verifies and surfaces exactly what
|
|
3114
|
+
// a merge can leave behind: id COLLISIONS (impossible for sharded ids; reported if two legacy flat ids
|
|
3115
|
+
// clashed) and Cells that need a fresh signature because the same Cell was edited on both branches.
|
|
3116
|
+
// A botched merge never goes green silently — it shows here and the gate stays blocked.
|
|
3117
|
+
function cmdMerge(flags) {
|
|
3118
|
+
const { p, config, lock } = loadState();
|
|
3119
|
+
if (!config) return fail('run `yay init` first');
|
|
3120
|
+
const manifest = buildManifest(p.root);
|
|
3121
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root), root: trustRootPin(flags) });
|
|
3122
|
+
const dups = (manifest.problems || []).filter((x) => /duplicate Cell id/.test(x.error || ''));
|
|
3123
|
+
const attention = Object.keys(verified.results).filter((id) => { const s = verified.results[id].state; return s === 'UNSIGNED' || s === 'RED'; });
|
|
3124
|
+
console.log(U.c.bold('Merge check · ') + config.project);
|
|
3125
|
+
if (!dups.length && !attention.length) {
|
|
3126
|
+
console.log(' ' + U.c.green('✓ clean') + U.c.dim(' — no id collisions, nothing needs re-signing. ') + (verified.passed ? U.c.green('gate PASS') : U.c.red('gate BLOCKED')));
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
if (dups.length) {
|
|
3130
|
+
console.log('\n ' + U.c.red(`● ${dups.length} duplicate Cell id(s)`) + U.c.dim(' — two Cells claim the same id (a collision):'));
|
|
3131
|
+
for (const d of dups) console.log(' ' + U.c.bold(d.id) + U.c.dim(' ' + d.file + ' — ' + d.error));
|
|
3132
|
+
console.log(' ' + U.c.dim('Renumber one side to a fresh ') + U.c.bold('C-' + ensureLocalShard(p) + '-<n>') + U.c.dim(' (see `yay id`), then re-sign it. Sharded ids prevent this going forward.'));
|
|
3133
|
+
}
|
|
3134
|
+
if (attention.length) {
|
|
3135
|
+
console.log('\n ' + U.c.yellow(`● ${attention.length} Cell(s) need attention`) + U.c.dim(' — unsigned or failing (a Cell edited on both branches drops its old signature):'));
|
|
3136
|
+
for (const id of attention.slice(0, 40)) console.log(' ' + U.c.bold(id) + ' ' + (verified.results[id].state === 'RED' ? U.c.red('RED — fix, then sign') : U.c.gray('UNSIGNED — sign')));
|
|
3137
|
+
if (attention.length > 40) console.log(' ' + U.c.dim(`… and ${attention.length - 40} more`));
|
|
3138
|
+
console.log(' ' + U.c.dim('Reconcile each, then ') + U.c.bold('yay sign') + U.c.dim('.'));
|
|
3139
|
+
}
|
|
3140
|
+
console.log('\n ' + (verified.passed ? U.c.green('gate PASS') : U.c.red('gate BLOCKED')) + U.c.dim(' — a merge never silently corrupts state; anything unresolved shows here and blocks the gate.'));
|
|
3141
|
+
process.exitCode = dups.length ? 1 : 0;
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
// Loose module-level code (a bare statement/call at import scope) can't be safely auto-wrapped — that
|
|
3145
|
+
// means rewriting import-time behaviour — so `adopt` never touches it. Surface it clearly and hand it to
|
|
3146
|
+
// the AI (Constitution Art. 6), rather than skipping it silently and leaving someone to think it's covered.
|
|
3147
|
+
function reportLooseCode(target) {
|
|
3148
|
+
try {
|
|
3149
|
+
const man = buildManifest(path.resolve(target));
|
|
3150
|
+
const loose = (man.untracked || []).filter((u) => u.kind === 'loose');
|
|
3151
|
+
if (!loose.length) return;
|
|
3152
|
+
console.log('\n' + U.c.pink(`◆ ${loose.length} module-level (loose) code region(s) stay Pink`) + U.c.dim(' — adopt won\'t rewrite code, so these aren\'t auto-wrapped:'));
|
|
3153
|
+
for (const u of loose.slice(0, 10)) console.log(' ' + U.c.dim(u.file + ':' + u.line) + (u.count ? U.c.dim(` (~${u.count} line(s))`) : ''));
|
|
3154
|
+
if (loose.length > 10) console.log(' ' + U.c.dim(`… and ${loose.length - 10} more`));
|
|
3155
|
+
console.log(' ' + U.c.dim('Ask your AI to wrap each into a spec\'d unit (Constitution Art. 6 — an IIFE around a spec\'d function in JS/TS, or an entry point in Python); it understands the code, so it wraps it safely.'));
|
|
3156
|
+
} catch (_) {}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
async function cmdAdopt(flags, positional) {
|
|
3160
|
+
const target = positional[0] || '.';
|
|
3161
|
+
const pAd = U.paths(path.resolve(target));
|
|
3162
|
+
const res = adopt(target, { dry: !!flags.dry, shard: ensureLocalShard(pAd), ledgerIds: ledgerCellIds(pAd) });
|
|
3163
|
+
if (!res.total) console.log(U.c.dim('nothing to adopt — no un-specced named units found (functions/methods).'));
|
|
3164
|
+
else {
|
|
3165
|
+
console.log((res.dry ? U.c.yellow('(dry run) ') : U.c.green('✓ ')) + `${res.total} draft Cell(s) across ${res.report.length} file(s):`);
|
|
3166
|
+
for (const r of res.report) console.log(' ' + U.c.dim(r.file) + ' +' + r.added);
|
|
3167
|
+
console.log('\n Next: prune each DERIVED spec, then ' + U.c.bold('yay sign') + '.');
|
|
3168
|
+
}
|
|
3169
|
+
reportLooseCode(target); // pink loose code adopt can't wrap → point to the AI (Art. 6)
|
|
3170
|
+
if (!flags.dry && res.total) { try { const { p, config } = loadState(); if (config) await foundationPosturePrompt(p, config, flags, process.stdin.isTTY); } catch (_) {} }
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// Batch settings live in config.json (committed, shared). Default: on, barrier 5.
|
|
3174
|
+
function batchConfig(config) {
|
|
3175
|
+
const b = (config && config.batch) || {};
|
|
3176
|
+
return { enabled: b.enabled !== false, barrier: (b.barrier >= 1 && b.barrier <= 100) ? b.barrier : 5 };
|
|
3177
|
+
}
|
|
3178
|
+
// `yay batch` — how the AI groups small changes into one Brief before asking you to sign.
|
|
3179
|
+
function cmdBatch(flags, positional) {
|
|
3180
|
+
const { p, config } = loadState();
|
|
3181
|
+
if (!config) return fail('run `yay init` first');
|
|
3182
|
+
const cur = batchConfig(config);
|
|
3183
|
+
const sub = positional[0];
|
|
3184
|
+
const setN = (sub && /^\d+$/.test(sub)) ? parseInt(sub, 10) : (flags.barrier && flags.barrier !== true ? parseInt(flags.barrier, 10) : null);
|
|
3185
|
+
if (sub === 'off' || flags.off) { config.batch = { enabled: false, barrier: cur.barrier }; U.writeJSON(p.config, config); console.log(U.c.green('✓ batch mode OFF') + U.c.dim(' — every change gets its own Brief. Commit .yaylayer/config.json.')); return; }
|
|
3186
|
+
if (sub === 'on' || flags.on) { config.batch = { enabled: true, barrier: cur.barrier }; U.writeJSON(p.config, config); console.log(U.c.green(`✓ batch mode ON`) + U.c.dim(` — barrier ${cur.barrier}. Commit .yaylayer/config.json.`)); return; }
|
|
3187
|
+
if (setN != null) {
|
|
3188
|
+
if (!(setN >= 1 && setN <= 100)) return fail('barrier must be a number 1–100');
|
|
3189
|
+
config.batch = { enabled: true, barrier: setN }; U.writeJSON(p.config, config);
|
|
3190
|
+
console.log(U.c.green(`✓ batch barrier set to ${setN}`) + U.c.dim(' — the AI asks whether to close the batch after this many small changes. Commit .yaylayer/config.json.'));
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
console.log(U.c.bold('Batch mode') + (cur.enabled ? U.c.green(' ON') + U.c.dim(` · barrier ${cur.barrier}`) : U.c.yellow(' OFF')));
|
|
3194
|
+
console.log(U.c.dim(' Small, low-risk changes are grouped into one Brief; at the barrier the AI asks whether to close it and sign'));
|
|
3195
|
+
console.log(U.c.dim(' (in Autopilot the batched Brief is delegated under the grant instead). Sensitive / behaviour-changing edits always get their own Brief.'));
|
|
3196
|
+
console.log(U.c.dim(' set: ') + U.c.bold('yay batch <n>') + U.c.dim(' · disable: ') + U.c.bold('yay batch off') + U.c.dim(' · enable: ') + U.c.bold('yay batch on'));
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function cmdStatus(flags) {
|
|
3200
|
+
flags = flags || {};
|
|
3201
|
+
const { p, config, lock } = loadState();
|
|
3202
|
+
if (!config) return console.log(U.c.dim('not initialized — run `yay init`'));
|
|
3203
|
+
const manifest = buildManifest(p.root);
|
|
3204
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: loadRoster(p), grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
3205
|
+
const c = verified.counts;
|
|
3206
|
+
console.log(U.c.bold(config.project) + U.c.dim(` · ${Object.keys(manifest.cells).length} Cells · ${Object.keys(config.signers).length} signer(s)`));
|
|
3207
|
+
console.log(' ' + U.c.green(`${c.GREEN}●`) + ' ' + U.c.yellow(`${c.YELLOW}●`) + ' ' + U.c.red(`${c.RED}●`) + ' ' + U.c.gray(`${c.UNSIGNED}○`) + ' ' + (verified.passed ? U.c.green('PASS') : U.c.red('BLOCKED')));
|
|
3208
|
+
const bc = batchConfig(config);
|
|
3209
|
+
if (bc.enabled && c.UNSIGNED > 0) console.log(' ' + U.c.dim(`${c.UNSIGNED} unsigned Cell(s) staged toward the batch (barrier ${bc.barrier}) — `) + U.c.bold('yay sign') + U.c.dim(' to close it.'));
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
function matcherStr(m) {
|
|
3213
|
+
m = m || {};
|
|
3214
|
+
return [m.path && `path ${m.path}`, m.tag && `tag ${m.tag}`, m.module && `module ${m.module}`].filter(Boolean).join(' · ') || '(no matcher — matches nothing)';
|
|
3215
|
+
}
|
|
3216
|
+
function printRules(rules) {
|
|
3217
|
+
for (const r of rules) {
|
|
3218
|
+
if (r.inert) {
|
|
3219
|
+
const lv = String(r.inert).toLowerCase();
|
|
3220
|
+
const desc = lv === 'block' ? U.c.red('inert: block') + U.c.dim(' — inert code gate-blocks') : lv === 'note' ? 'inert: note' + U.c.dim(' — findings shown as info only') : U.c.yellow('inert: yellow') + U.c.dim(' — inert code caps at Yellow');
|
|
3221
|
+
console.log(' ' + matcherStr(r.match) + U.c.dim(' → ') + desc);
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
if (r.ignore) {
|
|
3225
|
+
console.log(' ' + matcherStr(r.match) + U.c.dim(' → ') + 'ignore: source' + U.c.dim(' — source here may be .yaylayerignore\'d (kept out of the gate)'));
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
const who = r.signer || (r.signers || []).join(' or ') || '?';
|
|
3229
|
+
console.log(' ' + U.c.accent(who) + U.c.dim(' must sign ') + matcherStr(r.match));
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
// `yay policy` — the ENFORCED policy lives owner-signed in the roster (tamper-evident);
|
|
3233
|
+
// `.yaylayer/policy.json` is the editable DRAFT. `--init` writes a neutral template;
|
|
3234
|
+
// `--set` owner-signs the draft into effect. Neutral (no rules) blocks nothing.
|
|
3235
|
+
// `yay tags` — the project's Brief-tag vocabulary. Choose/switch a starter set, or edit it.
|
|
3236
|
+
function cmdTags(flags, positional) {
|
|
3237
|
+
const { p, config, lock } = loadState();
|
|
3238
|
+
if (!config) return fail('run `yay init` first');
|
|
3239
|
+
const sub = positional[0];
|
|
3240
|
+
const cur = tagsMod.loadTags(p);
|
|
3241
|
+
// Tags already baked into signed Briefs — their label is inside the signature and can't be
|
|
3242
|
+
// rewritten, so renaming one would split the history. Count uses to guard rename/remove.
|
|
3243
|
+
const tagUses = {};
|
|
3244
|
+
for (const a of (lock.approvals || [])) for (const t of ((a.brief && a.brief.tags) || [])) tagUses[tagsMod.norm(t)] = (tagUses[tagsMod.norm(t)] || 0) + 1;
|
|
3245
|
+
|
|
3246
|
+
if (sub === 'sets' || flags['list-sets']) {
|
|
3247
|
+
console.log(U.c.bold('Starter tag sets') + U.c.dim(' — pick one with `yay tags --set <id>`:'));
|
|
3248
|
+
for (const s of tagsMod.TAG_SETS) {
|
|
3249
|
+
console.log(' ' + U.c.accent(s.id.padEnd(15)) + s.name + U.c.dim(' — ' + s.desc));
|
|
3250
|
+
console.log(' ' + U.c.dim(s.tags.join(', ')));
|
|
3251
|
+
}
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
const setId = (flags.set && flags.set !== true) ? String(flags.set) : (sub === 'set' ? positional[1] : null);
|
|
3256
|
+
if (setId) {
|
|
3257
|
+
if (setId === 'custom') {
|
|
3258
|
+
// tags.json is COMMITTED (a shared project vocabulary) — never gitignored.
|
|
3259
|
+
tagsMod.saveTags(p, { project: config.project, set: 'custom', tags: tagsMod.CUSTOM_SEED.slice(), descriptions: {} });
|
|
3260
|
+
console.log(U.c.green('✓ custom placeholders') + U.c.dim(' (Custom 1–4) → .yaylayer/tags.json. Relabel with ') + U.c.bold('yay tags rename "Custom 1" "…"') + U.c.dim(' or the dashboard Tags tab.'));
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
const set = tagsMod.setById(setId);
|
|
3264
|
+
if (!set) return fail(`unknown tag set "${setId}" — options: ${tagsMod.TAG_SETS.map((s) => s.id).join(', ')}, custom (see \`yay tags sets\`)`);
|
|
3265
|
+
tagsMod.saveTags(p, { project: config.project, set: set.id, tags: set.tags.slice() });
|
|
3266
|
+
console.log(U.c.green(`✓ tag set → "${set.name}"`) + U.c.dim(` (${set.tags.length} tags). Commit .yaylayer/tags.json.`));
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
if (sub === 'add') {
|
|
3271
|
+
const t = cur || { project: config.project, set: 'custom', tags: [] };
|
|
3272
|
+
const toAdd = tagsMod.parseTags([], positional.slice(1).join(','));
|
|
3273
|
+
if (!toAdd.length) return fail('nothing to add — e.g. `yay tags add "Payments"`');
|
|
3274
|
+
let n = 0; for (const x of toAdd) if (!tagsMod.isKnown(t.tags, x)) { t.tags.push(x); n++; }
|
|
3275
|
+
tagsMod.saveTags(p, t);
|
|
3276
|
+
console.log(U.c.green(`✓ added ${n} tag(s)`) + U.c.dim(` — pool is now ${t.tags.length}. Commit .yaylayer/tags.json.`));
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
3279
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
3280
|
+
if (!cur) return fail('no tag pool yet — `yay tags --set <id>` first.');
|
|
3281
|
+
const toRem = tagsMod.parseTags([], positional.slice(1).join(','));
|
|
3282
|
+
const before = cur.tags.length;
|
|
3283
|
+
const removedUsed = toRem.map((r) => tagUses[tagsMod.norm(r)] || 0).reduce((a, b) => a + b, 0);
|
|
3284
|
+
cur.tags = cur.tags.filter((x) => !toRem.some((r) => tagsMod.norm(r) === tagsMod.norm(x)));
|
|
3285
|
+
if (cur.descriptions) for (const r of toRem) for (const k of Object.keys(cur.descriptions)) if (tagsMod.norm(k) === tagsMod.norm(r)) delete cur.descriptions[k];
|
|
3286
|
+
tagsMod.saveTags(p, cur);
|
|
3287
|
+
console.log(U.c.green(`✓ removed ${before - cur.tags.length} tag(s)`) + U.c.dim(` — pool is now ${cur.tags.length}. Commit .yaylayer/tags.json.`));
|
|
3288
|
+
if (removedUsed) console.log(U.c.dim(` note: ${removedUsed} signed Brief(s) still carry a removed tag in their history (shown under “Retired” in the dashboard) — that's preserved, it just won't be offered for new Briefs.`));
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
if (sub === 'rename') {
|
|
3292
|
+
if (!cur) return fail('no tag pool yet — `yay tags --set <id>` first.');
|
|
3293
|
+
const from = positional[1]; const to = positional[2];
|
|
3294
|
+
if (!from || !to) return fail('usage: yay tags rename "Custom 1" "Payments"');
|
|
3295
|
+
const i = cur.tags.findIndex((t) => tagsMod.norm(t) === tagsMod.norm(from));
|
|
3296
|
+
if (i < 0) return fail(`no tag "${from}" in the pool.`);
|
|
3297
|
+
if (tagUses[tagsMod.norm(from)]) return fail(`"${cur.tags[i]}" is already in ${tagUses[tagsMod.norm(from)]} signed Brief(s) — renaming it would split the history (past Briefs keep the old label in their signatures). Add a new tag with \`yay tags add\` instead, or \`yay tags remove\` it to stop offering it.`);
|
|
3298
|
+
const old = cur.tags[i]; cur.tags[i] = String(to).trim();
|
|
3299
|
+
cur.descriptions = cur.descriptions || {};
|
|
3300
|
+
if (cur.descriptions[old] !== undefined) { cur.descriptions[cur.tags[i]] = cur.descriptions[old]; delete cur.descriptions[old]; }
|
|
3301
|
+
tagsMod.saveTags(p, cur);
|
|
3302
|
+
console.log(U.c.green(`✓ renamed "${old}" → "${cur.tags[i]}"`) + U.c.dim(' — Commit .yaylayer/tags.json.'));
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
if (sub === 'desc' || sub === 'describe') {
|
|
3306
|
+
if (!cur) return fail('no tag pool yet — `yay tags --set <id>` first.');
|
|
3307
|
+
const label = positional[1]; const text = positional.slice(2).join(' ').trim();
|
|
3308
|
+
if (!label) return fail('usage: yay tags desc "Tag" "what this tag covers"');
|
|
3309
|
+
const canon = cur.tags.find((t) => tagsMod.norm(t) === tagsMod.norm(label));
|
|
3310
|
+
if (!canon) return fail(`no tag "${label}" in the pool.`);
|
|
3311
|
+
cur.descriptions = cur.descriptions || {};
|
|
3312
|
+
if (text) cur.descriptions[canon] = text; else delete cur.descriptions[canon];
|
|
3313
|
+
tagsMod.saveTags(p, cur);
|
|
3314
|
+
console.log(U.c.green(`✓ ${text ? 'set' : 'cleared'} description for "${canon}"`) + U.c.dim(' — Commit .yaylayer/tags.json.'));
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
if (!cur) {
|
|
3319
|
+
console.log(U.c.dim('No tag pool set yet. Pick a starter set (Briefs are tagged from it):'));
|
|
3320
|
+
console.log(' ' + U.c.bold('yay tags --set responsibility') + U.c.dim(' (see all six with ') + U.c.bold('yay tags sets') + U.c.dim(', or ') + U.c.bold('--set custom') + U.c.dim(' for blank placeholders)'));
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
const set = tagsMod.setById(cur.set);
|
|
3324
|
+
const descs = cur.descriptions || {};
|
|
3325
|
+
console.log(U.c.bold('Brief tags') + U.c.dim(` — set: ${set ? set.name : cur.set} · ${cur.tags.length} tags · every Brief is tagged from this pool:`));
|
|
3326
|
+
cur.tags.forEach((t) => console.log(' ' + U.c.accent(t) + (descs[t] ? U.c.dim(' — ' + descs[t]) : '')));
|
|
3327
|
+
const plan = tagsMod.planStatus(cur);
|
|
3328
|
+
if (!plan.ok) {
|
|
3329
|
+
if (plan.placeholders.length) console.log('\n' + U.c.yellow(`⚠ tag plan UNFINISHED — relabel ${plan.placeholders.map((t) => `"${t}"`).join(', ')} (\`yay tags rename\`)`) + U.c.dim(' — signing is blocked until the plan is finished (no placeholders, ≥' + tagsMod.MIN_PLAN_TAGS + ' unique tags).'));
|
|
3330
|
+
else console.log('\n' + U.c.yellow(`⚠ tag plan UNFINISHED — ${plan.unique}/${tagsMod.MIN_PLAN_TAGS} unique tags`) + U.c.dim(' — add more (`yay tags add "<Tag>"`); signing is blocked until the plan has at least ' + tagsMod.MIN_PLAN_TAGS + '.'));
|
|
3331
|
+
}
|
|
3332
|
+
console.log('\n' + U.c.dim('switch: ') + U.c.bold('yay tags --set <id>') + U.c.dim(' · add/remove: ') + U.c.bold('yay tags add|remove "Tag"') + U.c.dim(' · relabel: ') + U.c.bold('yay tags rename "A" "B"') + U.c.dim(' · describe: ') + U.c.bold('yay tags desc "Tag" "…"'));
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
async function cmdPolicy(flags) {
|
|
3336
|
+
const { p, config, lock } = loadState();
|
|
3337
|
+
if (!config) return fail('run `yay init` first');
|
|
3338
|
+
const ppath = policyMod.policyPath(p);
|
|
3339
|
+
if (flags.init) {
|
|
3340
|
+
if (fs.existsSync(ppath) && !flags.force) return fail('.yaylayer/policy.json already exists — pass --force to overwrite');
|
|
3341
|
+
fs.writeFileSync(ppath, policyMod.TEMPLATE);
|
|
3342
|
+
console.log(U.c.green('✓ wrote .yaylayer/policy.json') + U.c.dim(' — neutral (no rules). Fill in the commented examples, then ') + U.c.bold('yay policy --set') + U.c.dim(' to owner-sign it into effect.'));
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3345
|
+
if (flags.set) return cmdPolicySet(p, config, flags);
|
|
3346
|
+
|
|
3347
|
+
const rlog = loadRoster(p);
|
|
3348
|
+
const drv = rlog ? rosterMod.deriveRoster(rlog) : null;
|
|
3349
|
+
const enforced = (drv && drv.policy) || { rules: [] };
|
|
3350
|
+
const draft = policyMod.loadPolicy(p);
|
|
3351
|
+
if (draft.problem) console.log(U.c.yellow(' ⚠ ' + draft.problem));
|
|
3352
|
+
if (!enforced.rules.length) console.log(U.c.dim('Enforced policy: none — every enrolled signer is treated the same (neutral).'));
|
|
3353
|
+
else { console.log(U.c.bold('Enforced policy') + U.c.dim(' (owner-signed, in the roster):')); printRules(enforced.rules); }
|
|
3354
|
+
|
|
3355
|
+
if (JSON.stringify(draft.rules) !== JSON.stringify(enforced.rules)) {
|
|
3356
|
+
console.log('\n' + U.c.yellow('Draft in .yaylayer/policy.json differs from what is enforced:'));
|
|
3357
|
+
if (draft.rules.length) printRules(draft.rules); else console.log(U.c.dim(' (neutral — no rules)'));
|
|
3358
|
+
console.log(U.c.dim(' run ') + U.c.bold('yay policy --set') + U.c.dim(' to owner-sign the draft into effect.'));
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
const manifest = buildManifest(p.root);
|
|
3362
|
+
const verified = verifyManifest(manifest, lock, config, { mutate: false, roster: rlog, grants: loadGrants(p), rejections: loadRejections(p), tracked: trackedFiles(p.root),root: trustRootPin(flags) });
|
|
3363
|
+
const viol = Object.values(verified.results).filter((x) => x.policyOk === false);
|
|
3364
|
+
if (viol.length) {
|
|
3365
|
+
console.log('\n' + U.c.red(` ${viol.length} Cell(s) violate the enforced policy:`));
|
|
3366
|
+
for (const v of viol) { const note = (v.notes.find((n) => /^policy:/.test(n.text)) || {}).text || ''; console.log(' ' + U.c.red('● ') + v.id + U.c.dim(' — ' + note)); }
|
|
3367
|
+
} else if (enforced.rules.length) {
|
|
3368
|
+
console.log('\n' + U.c.green(' ✓ all Cells satisfy the policy.'));
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
// Owner-sign the draft policy.json into the roster (a `policy` governance event).
|
|
3372
|
+
async function cmdPolicySet(p, config, flags) {
|
|
3373
|
+
const rlog = loadRoster(p);
|
|
3374
|
+
if (!rlog || !rlog.events || !rlog.events.length) return fail('no signed roster yet — run `yay init` first to establish the trust root.');
|
|
3375
|
+
const draft = policyMod.loadPolicy(p);
|
|
3376
|
+
if (draft.problem) return fail(draft.problem + ' — fix .yaylayer/policy.json first');
|
|
3377
|
+
const rules = draft.rules || [];
|
|
3378
|
+
const ev = { id: rosterMod.nextEventId(rlog), type: 'policy', rules, by: null, prev: rlog.events[rlog.events.length - 1].id, nonce: C.randomNonce(), at: new Date().toISOString() };
|
|
3379
|
+
const summary = {
|
|
3380
|
+
title: `Set the signing policy (${rules.length} rule${rules.length === 1 ? '' : 's'}) in ${config.project}?`,
|
|
3381
|
+
rows: rules.length ? rules.map((r) => ({ k: r.signer || (r.signers || []).join(' or ') || '?', v: matcherStr(r.match) })) : [{ k: '(neutral)', v: 'no rules — every signer treated the same' }],
|
|
3382
|
+
warn: 'Changes who must sign what. Owner-signed and tamper-evident; enforced at the gate.',
|
|
3383
|
+
};
|
|
3384
|
+
const signed = await authorizeRosterEvent(p, config, rlog, ev, flags, summary);
|
|
3385
|
+
if (!signed) return;
|
|
3386
|
+
const test = rosterMod.deriveRoster({ ...rlog, events: rlog.events.concat([signed]) });
|
|
3387
|
+
if (test.problems.length) return fail('refusing to write — ' + test.problems.join('; '));
|
|
3388
|
+
rlog.events.push(signed);
|
|
3389
|
+
U.writeJSON(rosterPath(p), rlog);
|
|
3390
|
+
console.log(U.c.green(`✓ signing policy set (${rules.length} rule${rules.length === 1 ? '' : 's'})`) + U.c.dim(` — authorized by ${signed.by}. Commit .yaylayer/roster.json; it's now enforced at the gate.`));
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function fail(msg) { console.error(U.c.red('error: ') + msg); process.exitCode = 1; }
|
|
3394
|
+
|
|
3395
|
+
const HELP = `yay — a protocol for provable, signed AI code
|
|
3396
|
+
|
|
3397
|
+
yay init [dir] guided setup: files → signing key → adopt → instruct your AI
|
|
3398
|
+
signing key: local · mobile over your LAN · mobile over relay.yaylayer.com (--relay, for off-LAN; E2E)
|
|
3399
|
+
flags: --project <name> --key local|mobile [--relay|--lan] --name <you> --adopt|--no-adopt --constitution <keys|all>
|
|
3400
|
+
--plan|--no-plan --provider anthropic|openai|custom [--base-url url] [--model m] --api-key <k>
|
|
3401
|
+
yay constitution --for <k> write the Constitution where an AI harness auto-reads it
|
|
3402
|
+
(--for claude,agents,cursor,copilot,windsurf,cline,gemini,generic | all · --list)
|
|
3403
|
+
yay keygen --name <you> create your signing key
|
|
3404
|
+
yay adopt [path] [--dry] scaffold draft specs over existing code
|
|
3405
|
+
yay id show THIS clone's Cell-id shard (C-<shard>-n) + the next id it would mint —
|
|
3406
|
+
each working copy gets its own shard so ids never collide when branches merge
|
|
3407
|
+
yay merge run after a git merge/pull: re-verify + list any id collisions and Cells that
|
|
3408
|
+
need re-signing (edited on both sides). A botched merge never goes green silently.
|
|
3409
|
+
yay pair [--name you] pair your phone as the signer (key stays on the phone; scan the QR) · SSL on by default (--no-https)
|
|
3410
|
+
--relay routes via relay.yaylayer.com (off-LAN, end-to-end encrypted) · --lan forces the local path
|
|
3411
|
+
the FIRST pairing makes the phone the trust root — no local key needed
|
|
3412
|
+
yay enroll --name X --pubkey <b64> enroll another signer via an OWNER-signed event (--role owner|signer)
|
|
3413
|
+
yay policy [--init|--set] signing policy (who must sign what). --init writes a neutral template to
|
|
3414
|
+
.yaylayer/policy.json; edit it, then --set owner-signs it into the roster
|
|
3415
|
+
(tamper-evident, enforced at the gate). Neutral by default — no rules, no blocking.
|
|
3416
|
+
yay invite "Bob" [--role signer|owner] mint a 30-min link a teammate opens to request to join;
|
|
3417
|
+
you approve on your phone (needs a running dashboard). No pubkey to copy.
|
|
3418
|
+
authorize with a local owner key, or --phone to approve on an owner's phone
|
|
3419
|
+
yay revoke --name X [--pubkey <b64>] revoke one key (or the whole identity) via an owner-signed event (--phone)
|
|
3420
|
+
yay reroot [--phone] retire the current trust root and establish a new one (key lost/compromised)
|
|
3421
|
+
yay protect [--mode guarded|strict|--off] FOUNDATION SEAL: owner-sign a baseline of the fixed core files
|
|
3422
|
+
(Constitution, CI workflow, .gitignore, protocol) so any change is REVEALED at
|
|
3423
|
+
verify. --add/--remove/--ignore <glob> tune it. Guarded warns; strict blocks.
|
|
3424
|
+
yay ask "<question>" ask the configured AI about THIS repo + the manual (needs an LLM key in .env;
|
|
3425
|
+
also available as the Ask tab in the dashboard). e.g. which cells need approval?
|
|
3426
|
+
yay grant [--for 2h] [--count 20] Autopilot: owner-signed capability ENVELOPE → the AI approves in-scope
|
|
3427
|
+
(delegated), non-sensitive Cells unattended (no phone) until it expires.
|
|
3428
|
+
envelope: --cell IDs · --allow/--deny "glob" · --allow-tag/--deny-tag TAG · --max-risk low|medium|high
|
|
3429
|
+
--child-grants [--max-depth N] (permit attenuating sub-grants) · --deps/--deploy (recorded)
|
|
3430
|
+
SECURITY GUARD is ON by default — auth/payments/secrets/deploy/CI need a real signature;
|
|
3431
|
+
lift with --no-guard. yay grant list · yay grant revoke [id] (stop it) · sensitive /
|
|
3432
|
+
code-pinned / policy non-delegable ({ "delegable": false }) always need a real sign
|
|
3433
|
+
yay ratify [--sign] list delegated Cells awaiting ratification; --sign signs them for real (human)
|
|
3434
|
+
--reject --reason "<why>" [--category …] [--cell IDs] records a signed, append-only
|
|
3435
|
+
REJECTION (kept as provenance; the code stays unsigned until fixed)
|
|
3436
|
+
yay sign [--cell IDs] approve specs using THIS project's method (phone or local) — no flag needed
|
|
3437
|
+
override with --phone / --local · SSL on by default (--no-https) · --cell to sign a subset
|
|
3438
|
+
a Brief is required by default (Standard §5): --brief "<what you ordered>" supplies it,
|
|
3439
|
+
--title "<headline>" gives it a scannable title (prompted at a terminal)
|
|
3440
|
+
(editable on the phone) · you're prompted if omitted at a terminal · --no-brief skips a trivial re-sign
|
|
3441
|
+
--name "<teammate>" (relay projects) routes the request to THEIR inbox and returns a request id
|
|
3442
|
+
(fire-and-return); collect it later with --check <id> (or --check for all pending)
|
|
3443
|
+
yay inbox print YOUR on-duty relay link — open it on your phone to receive requests addressed to you
|
|
3444
|
+
yay requests [done <id>] list plain requests queued from the dashboard's "Request a change" button (the AI
|
|
3445
|
+
turns each into a Brief + Cells); "done <id>" or "clear" removes handled ones
|
|
3446
|
+
yay briefs [--by-tag] [--tag X] the Brief ledger in the terminal — newest first; --by-tag groups by
|
|
3447
|
+
tag (tag first, date second); --tag <name> filters to one tag
|
|
3448
|
+
yay tags [--set id|add|remove|rename|desc|sets] the project's Brief-tag vocabulary — every Brief is tagged
|
|
3449
|
+
from it. --set <id> (or --set custom for blank placeholders) · add/remove "Tag" ·
|
|
3450
|
+
rename "A" "B" (blocked once a tag is used in a signed Brief) · desc "Tag" "…" · sets
|
|
3451
|
+
yay batch [<n>|off|on] how the AI groups small changes into one Brief before signing (default barrier 5).
|
|
3452
|
+
"yay batch 8" raises it; "off" = a Brief per change. Batches are per-concern (tag).
|
|
3453
|
+
yay verify [--strict] [-d] the gate — paint every Cell; -d/--details prints each spec, code & checks
|
|
3454
|
+
--problems shows only non-green Cells · --no-mutate skips prover mutation grading
|
|
3455
|
+
yay attest [list|verify] mint a SIGNED verifier attestation over the current verdict (the verifier's own
|
|
3456
|
+
key vouches for "Green" — the third crypto identity). Run at commit / after a green
|
|
3457
|
+
verify. Refuses a blocked gate (--force records a failing one). Append-only, chained,
|
|
3458
|
+
capability-versioned. "list" shows the ledger; "verify" re-checks every attestation
|
|
3459
|
+
(--strict exits non-zero on any invalid) · "verify --registry" also cross-checks each
|
|
3460
|
+
attestation's capability against yaylayer.com/verify (catches over-claim). Key stays
|
|
3461
|
+
machine-side (gitignored); the public verifier of record is pinned in config. Anyone
|
|
3462
|
+
can also verify an attestation in-browser at https://yaylayer.com/verify (no upload).
|
|
3463
|
+
yay capability [--json] the verifier's DERIVED capability descriptor + fingerprint (provers, effect
|
|
3464
|
+
nets, checks, policy kinds); flags DRIFT if detectors changed without a version bump
|
|
3465
|
+
yay reverify re-run verification on the CURRENT tree at today's capability; on a change,
|
|
3466
|
+
APPEND a new attestation chained to the prior one (never rewrites old Green).
|
|
3467
|
+
yay reverify --all the HISTORICAL SWEEP: replay every preserved (Durable) snapshot through today's
|
|
3468
|
+
[--since D] [--eligible] verifier + diff vs its original verdict → a "verifier upgrade report." Read-only and
|
|
3469
|
+
[--attest] [-o f] [--json] KEYLESS by default; --attest mints signed, append-only reverification records.
|
|
3470
|
+
yay reverify posture GRANDFATHERING control: off (default — history grandfathered) · guarded (verify
|
|
3471
|
+
[off|guarded|strict] warns when history predates the current verifier) · strict (gate blocks until
|
|
3472
|
+
preserved history is re-verified under it). Gates on record existence, never on a key.
|
|
3473
|
+
yay witness [--strict] integrity witness — cross-check the attestation chain, the spec archive, and
|
|
3474
|
+
whether the latest attestation still covers the code (git/tree vs ledger vs archive)
|
|
3475
|
+
yay metrics earned-autonomy metrics from the delegation + ratification + rejection history
|
|
3476
|
+
(per-category rejection rates; suggests categories to stop delegating)
|
|
3477
|
+
yay archive [enable|…] Durable mode: keep an encrypted, sha256-anchored copy of SIGNED source so it
|
|
3478
|
+
survives git loss. enable/disable · (default) archive covered files · --forget
|
|
3479
|
+
<hash> --reason (signed tombstone) · --restore <hash> · --verify · install (git
|
|
3480
|
+
post-commit hook). Key is project-held ($YAY_ARCHIVE_KEY or a passphrase),
|
|
3481
|
+
NEVER stored by YayLayer; a pre-archive secret scan refuses to seal secrets
|
|
3482
|
+
yay plan [--provider anthropic|openai|custom] [--model m] [--base-url url]
|
|
3483
|
+
AI-synthesize a high-level System Plan → .yaylayer/plan.json
|
|
3484
|
+
key from .env (ANTHROPIC_API_KEY / OPENAI_API_KEY); custom = any OpenAI-compatible
|
|
3485
|
+
endpoint via --base-url (Ollama/LM Studio/vLLM/local — key optional)
|
|
3486
|
+
yay map [-o file.html] write the HTML flowchart (default: yay-layer-map.html)
|
|
3487
|
+
if plan generation is enabled it regenerates the System Plan; --no-plan skips it, --replan forces it
|
|
3488
|
+
yay dashboard [--port N] live control panel: map + auto-refresh + Run-tests & Regenerate-plan buttons (leave running; --open, SSL on by default: --no-https)
|
|
3489
|
+
yay test [--test "cmd"] run the project's own test suite (package.json "test" / config.test); non-zero exit on failure
|
|
3490
|
+
yay adversary [--cell IDs] spec-only adversary: an LLM sees ONLY the specs and writes probes to break the code (needs an LLM key)
|
|
3491
|
+
yay gate [dir] write the CI gate pipeline (+ --hook local pre-push) & print the
|
|
3492
|
+
branch-protection steps for your host. --for github|azure|gitlab|bitbucket|gitea|gerrit
|
|
3493
|
+
(default github) · flags: --scope <dir> --pkg <spec> --hook --force
|
|
3494
|
+
yay status one-line summary
|
|
3495
|
+
|
|
3496
|
+
docs: standard/STANDARD.md · CONSTITUTION.md · README.md`;
|
|
3497
|
+
|
|
3498
|
+
async function main() {
|
|
3499
|
+
loadDotenv();
|
|
3500
|
+
const [, , cmd, ...rest] = process.argv;
|
|
3501
|
+
const { flags, positional } = args(rest);
|
|
3502
|
+
switch (cmd) {
|
|
3503
|
+
case 'init': return cmdInit(flags, positional);
|
|
3504
|
+
case 'keygen': return cmdKeygen(flags);
|
|
3505
|
+
case 'sign': return cmdSign(flags, positional);
|
|
3506
|
+
case 'inbox': return cmdInbox(flags);
|
|
3507
|
+
case 'requests': case 'request': return cmdRequests(flags, positional);
|
|
3508
|
+
case 'tags': case 'tag': return cmdTags(flags, positional);
|
|
3509
|
+
case 'briefs': return cmdBriefs(flags);
|
|
3510
|
+
case 'pair': return cmdPair(flags);
|
|
3511
|
+
case 'enroll': return cmdEnroll(flags);
|
|
3512
|
+
case 'invite': return cmdInvite(flags, positional);
|
|
3513
|
+
case 'revoke': return cmdRevoke(flags);
|
|
3514
|
+
case 'reroot': return cmdReroot(flags);
|
|
3515
|
+
case 'protect': return cmdProtect(flags, positional);
|
|
3516
|
+
case 'ask': return cmdAsk(flags, positional);
|
|
3517
|
+
case 'grant': case 'autopilot': return cmdGrant(flags, positional);
|
|
3518
|
+
case 'ratify': return cmdRatify(flags);
|
|
3519
|
+
case 'verify': case 'check': return cmdVerify(flags);
|
|
3520
|
+
case 'attest': case 'attestation': return cmdAttest(flags, positional);
|
|
3521
|
+
case 'reverify': return cmdReverify(flags, positional);
|
|
3522
|
+
case 'capability': case 'caps': return cmdCapability(flags);
|
|
3523
|
+
case 'witness': return cmdWitness(flags);
|
|
3524
|
+
case 'metrics': return cmdMetrics(flags);
|
|
3525
|
+
case 'archive': return cmdArchive(flags, positional);
|
|
3526
|
+
case 'map': return cmdMap(flags);
|
|
3527
|
+
case 'dashboard': case 'serve': return cmdDashboard(flags);
|
|
3528
|
+
case 'test': case 'tests': return cmdTest(flags);
|
|
3529
|
+
case 'adversary': case 'adversarial': return cmdAdversary(flags);
|
|
3530
|
+
case 'plan': return cmdPlan(flags);
|
|
3531
|
+
case 'adopt': return cmdAdopt(flags, positional);
|
|
3532
|
+
case 'constitution': case 'rules': return cmdConstitution(flags, positional);
|
|
3533
|
+
case 'gate': case 'ci': return cmdGate(flags, positional);
|
|
3534
|
+
case 'id': case 'shard': return cmdId(flags);
|
|
3535
|
+
case 'merge': return cmdMerge(flags);
|
|
3536
|
+
case 'status': return cmdStatus(flags);
|
|
3537
|
+
case 'batch': return cmdBatch(flags, positional);
|
|
3538
|
+
case 'policy': return cmdPolicy(flags);
|
|
3539
|
+
case 'version': case '--version': case '-v': return console.log(require('../package.json').version);
|
|
3540
|
+
case undefined: case 'help': case '--help': case '-h': return console.log(HELP);
|
|
3541
|
+
default: console.error(U.c.red(`unknown command: ${cmd}`)); console.log(HELP); process.exitCode = 1;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
// Exit explicitly once the command resolves — routed HTTP calls (undici keep-alive)
|
|
3545
|
+
// and a ref'd stdin from prompts can otherwise keep the process alive after we're
|
|
3546
|
+
// done. `yay dashboard` never resolves (runs until Ctrl-C), so it's unaffected.
|
|
3547
|
+
main().then(
|
|
3548
|
+
() => process.exit(process.exitCode || 0),
|
|
3549
|
+
(e) => { console.error(U.c.red('error: ') + (e && e.message || e)); process.exit(1); },
|
|
3550
|
+
);
|