great-cto 3.76.1 → 3.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +3 -2
- package/board/agents/devops.md +19 -0
- package/board/agents/e2e-test-engineer.md +20 -1
- package/board/agents/release-manager.md +213 -0
- package/board/agents/security-officer.md +53 -14
- package/board/packages/board/lib/installed-version.mjs +52 -0
- package/board/packages/board/lib/routes.mjs +5 -12
- package/board/packages/board/public/assets/agent-groups.js +1 -1
- package/board/packages/board/public/index.html +3 -3
- package/board/scripts/codex-pipeline.mjs +57 -12
- package/board/scripts/lib/approval-level.mjs +9 -2
- package/board/scripts/lib/codex-pipeline.mjs +114 -29
- package/board/scripts/lib/codex-role-profiles.mjs +1 -0
- package/board/scripts/lib/exceptions.mjs +167 -0
- package/board/scripts/lib/gate-check-bound.mjs +478 -0
- package/board/scripts/lib/gate-check.mjs +173 -0
- package/board/scripts/lib/gate-reversibility.mjs +4 -0
- package/board/scripts/lib/gate-tier.mjs +1 -1
- package/board/scripts/lib/ship-evidence-context.mjs +174 -0
- package/board/scripts/lib/ship-evidence-identity.mjs +294 -0
- package/board/scripts/lib/work-tasks.mjs +16 -2
- package/board/shared/pipeline.toml +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const MAX_BYTES = 16777216;
|
|
4
|
+
const negative = value => ['FAIL', 'BLOCKED', 'REJECTED', 'UNKNOWN'].includes(value);
|
|
5
|
+
const cpOrder = (a, b) => {
|
|
6
|
+
const aa = Array.from(a, c => c.codePointAt(0)), bb = Array.from(b, c => c.codePointAt(0));
|
|
7
|
+
for (let i = 0; i < Math.min(aa.length, bb.length); i++) if (aa[i] !== bb[i]) return aa[i] - bb[i];
|
|
8
|
+
return aa.length - bb.length;
|
|
9
|
+
};
|
|
10
|
+
const bytes = s => Buffer.byteLength(s, 'utf8');
|
|
11
|
+
const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x) && Object.getPrototypeOf(x) === Object.prototype;
|
|
12
|
+
|
|
13
|
+
// Inspect descriptors, never property values: getters are not evidence.
|
|
14
|
+
function snapshot(value, depth = 0, ancestors = new Set()) {
|
|
15
|
+
if (depth > 32) throw new TypeError('JSON depth exceeds limit');
|
|
16
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
if (bytes(value) > 262144) throw new TypeError('JSON string exceeds limit');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
22
|
+
if (typeof value !== 'object' || (!Array.isArray(value) && !plain(value)) || ancestors.has(value)) throw new TypeError('Invalid JSON data');
|
|
23
|
+
ancestors.add(value);
|
|
24
|
+
const desc = Object.getOwnPropertyDescriptors(value);
|
|
25
|
+
if (Reflect.ownKeys(desc).some(k => typeof k !== 'string')) throw new TypeError('Symbol key');
|
|
26
|
+
const array = Array.isArray(value), out = array ? [] : {};
|
|
27
|
+
const keys = Object.keys(desc);
|
|
28
|
+
if (array && (!Number.isSafeInteger(desc.length?.value) || keys.length !== desc.length.value + 1)) throw new TypeError('Sparse or extended array');
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (array && key === 'length') continue;
|
|
31
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key) || bytes(key) > 262144) throw new TypeError('Invalid JSON key');
|
|
32
|
+
const d = desc[key];
|
|
33
|
+
if (!('value' in d) || !d.enumerable || (array && !/^(0|[1-9][0-9]*)$/.test(key))) throw new TypeError('Invalid JSON descriptor');
|
|
34
|
+
out[key] = snapshot(d.value, depth + 1, ancestors);
|
|
35
|
+
}
|
|
36
|
+
ancestors.delete(value);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function canonical(value) {
|
|
40
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
41
|
+
if (plain(value)) return `{${Object.keys(value).sort(cpOrder).map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
function validated(value) {
|
|
45
|
+
const data = snapshot(value);
|
|
46
|
+
if (bytes(JSON.stringify(data)) > MAX_BYTES) throw new TypeError('JSON input exceeds limit');
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
export function stableDigest(jsonValue) {
|
|
50
|
+
try { return createHash('sha256').update(canonical(validated(jsonValue)), 'utf8').digest('hex'); }
|
|
51
|
+
catch { throw new TypeError('Invalid bounded JSON data'); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const id = x => typeof x === 'string' && bytes(x) > 0 && bytes(x) <= 256 && !/[\u0000-\u001f\u007f-\u009f]/u.test(x);
|
|
55
|
+
const role = x => typeof x === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(x);
|
|
56
|
+
const sha = x => typeof x === 'string' && /^[0-9a-f]{64}$/.test(x);
|
|
57
|
+
const oid = x => typeof x === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(x);
|
|
58
|
+
const ms = x => Number.isSafeInteger(x) && x >= 0;
|
|
59
|
+
const one = (...values) => x => values.includes(x);
|
|
60
|
+
const literal = value => x => x === value;
|
|
61
|
+
const nullableSha = x => x === null || sha(x);
|
|
62
|
+
const array = (check, cap = 10000) => x => Array.isArray(x) && x.length <= cap && x.every(check);
|
|
63
|
+
const ids = array(id);
|
|
64
|
+
const checks = array(id, 256);
|
|
65
|
+
function object(x, schema) {
|
|
66
|
+
return plain(x) && Object.keys(x).length === Object.keys(schema).length && Object.entries(schema).every(([k, check]) => Object.hasOwn(x, k) && check(x[k]));
|
|
67
|
+
}
|
|
68
|
+
const origin = x => object(x, {journalId: id, ordinal: ms, rawDigest: sha});
|
|
69
|
+
const reviewer = x => object(x, {role, requiredChecks: checks, independentOf: ids}) && new Set(x.requiredChecks).size === x.requiredChecks.length;
|
|
70
|
+
const source = x => object(x, {head: oid, tree: oid, clean: x => typeof x === 'boolean', dirtyDigest: nullableSha, exclusionsDigest: sha}) && x.head.length === x.tree.length && (x.clean ? x.dirtyDigest === null : sha(x.dirtyDigest));
|
|
71
|
+
const evidenceClass = one('SOURCE_REVIEW', 'LOCAL_FIXTURE');
|
|
72
|
+
const expectedSchema = x => object(x, {version: literal('ship-context/v1'), repositoryId: id, projectId: id, source, taskId: id, runId: id, gateId: literal('gate:ship'), policyDigest: sha, requirementsDigest: sha, evidenceClass, requiredReviewers: array(reviewer, 128)});
|
|
73
|
+
const invocation = x => object(x, {version: literal('ship-assignment/v1'), reviewerInvocationId: id, role, expectedContextDigest: sha, taskId: id, runId: id, reviewerActorId: id, independentOf: ids, requiredChecks: checks, issuedAtMs: ms, expiresAtMs: ms, revoked: x => typeof x === 'boolean', origin: literal('CONTROLLER')}) && x.expiresAtMs > x.issuedAtMs;
|
|
74
|
+
const checkEvidence = x => object(x, {checkId: id, state: one('PASS', 'FAIL', 'NOT_CHECKED'), evidenceDigest: sha});
|
|
75
|
+
const outcome = one('PASS', 'FAIL', 'BLOCKED', 'REJECTED', 'UNKNOWN');
|
|
76
|
+
const record = x => object(x, {version: literal('ship-evidence/v2'), recordId: id, reviewerInvocationId: id, expectedContextDigest: sha, role, outcome, checkEvidence: array(checkEvidence, 256), reviewedEvidenceDigest: sha, authority: one('CONTROLLED_REVIEW', 'NONE', 'ADVISORY'), releaseEligibility: one('SOURCE_GATE_ONLY', 'NOT_CHECKED'), evidenceClass, findingIds: ids, startedAtMs: ms, finishedAtMs: ms, rawRecordDigest: sha, origin}) && bytes(JSON.stringify(x)) <= 262144;
|
|
77
|
+
const obligation = x => object(x, {findingId: id, originRecordDigest: sha, originContextDigest: nullableSha, role, severity: one('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'), category: id, origin});
|
|
78
|
+
function resolution(x) {
|
|
79
|
+
const schema = {version: literal('ship-resolution/v1'), resolutionId: id, kind: one('TECHNICAL', 'OWNER_SUPERSESSION'), findingId: id, originRecordDigest: sha, originContextDigest: nullableSha, targetContextDigest: sha, reviewerInvocationId: id, reviewRecordId: id, fixCommit: oid, changeEvidenceDigest: sha, verificationEvidenceDigest: sha, implementerActorId: id};
|
|
80
|
+
if (x?.kind === 'OWNER_SUPERSESSION') Object.assign(schema, {successorIdentity: id, coverageDisposition: id, ownerDecisionId: id});
|
|
81
|
+
return object(x, schema);
|
|
82
|
+
}
|
|
83
|
+
const task = x => object(x, {id, state: one('open', 'in_progress', 'closed', 'blocked', 'failed', 'unverified', 'not_run', 'not-run')});
|
|
84
|
+
const ownerDecision = x => object(x, {decisionId: id, targetContextDigest: sha, findingId: id, successorIdentity: id, coverageDisposition: id, authenticated: x => typeof x === 'boolean'});
|
|
85
|
+
const readSchema = x => object(x, {version: literal('ship-read-status/v1'), nowMs: ms, context: one('COMPLETE', 'UNKNOWN'), assignments: one('COMPLETE', 'UNKNOWN'), journals: one('COMPLETE', 'UNKNOWN'), tasks: one('COMPLETE', 'UNKNOWN'), policy: one('COMPLETE', 'UNKNOWN'), origins: one('COMPLETE', 'UNKNOWN'), trustedOwnerDecisions: array(ownerDecision)});
|
|
86
|
+
const sortBy = key => (a, b) => cpOrder(a[key], b[key]) || cpOrder(canonical(a), canonical(b));
|
|
87
|
+
function preferObligation(a, b) {
|
|
88
|
+
const severities = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
|
|
89
|
+
const delta = severities.indexOf(a.severity) - severities.indexOf(b.severity);
|
|
90
|
+
return delta < 0 || (delta === 0 && cpOrder(canonical(a), canonical(b)) < 0) ? a : b;
|
|
91
|
+
}
|
|
92
|
+
function emptyResult() {
|
|
93
|
+
return {version: 'ship-result/v1', state: 'UNKNOWN', contextDigest: null, mandatoryReviews: [], openObligations: [], resolvedObligations: [], supersededObligations: [], blockingTasks: [], diagnostics: [], authority: 'NONE', releaseEligibility: 'NOT_CHECKED', evidenceClass: 'LOCAL_FIXTURE'};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function negativeObligations(r) {
|
|
97
|
+
if (!plain(r) || !negative(r.outcome) || !sha(r.rawRecordDigest) || !role(r.role) || !origin(r.origin) || !nullableSha(r.expectedContextDigest) || !ids(r.findingIds)) return [];
|
|
98
|
+
return (r.findingIds.length ? r.findingIds : [`record:${r.rawRecordDigest}`]).map(findingId => ({findingId, originRecordDigest: r.rawRecordDigest, originContextDigest: r.expectedContextDigest, role: r.role, severity: 'UNKNOWN', category: 'review', origin: r.origin}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A hostile sibling must not erase previously supplied, independently valid findings.
|
|
102
|
+
// Each recovery read is descriptor-only and bounded; no accessor is invoked.
|
|
103
|
+
function recoverObligations(input) {
|
|
104
|
+
const recovered = [];
|
|
105
|
+
try {
|
|
106
|
+
if (!plain(input)) return recovered;
|
|
107
|
+
const top = Object.getOwnPropertyDescriptors(input);
|
|
108
|
+
for (const key of ['obligations', 'records']) {
|
|
109
|
+
const value = top[key]?.value;
|
|
110
|
+
if (!Array.isArray(value)) continue;
|
|
111
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), length = descriptors.length?.value;
|
|
112
|
+
if (!ms(length) || length > 10000) continue;
|
|
113
|
+
for (let i = 0; i < length; i++) {
|
|
114
|
+
try {
|
|
115
|
+
const item = descriptors[i]?.value;
|
|
116
|
+
if (key === 'obligations') {
|
|
117
|
+
const data = validated(item);
|
|
118
|
+
if (obligation(data)) recovered.push(data);
|
|
119
|
+
} else {
|
|
120
|
+
if (!plain(item)) continue;
|
|
121
|
+
const fields = Object.getOwnPropertyDescriptors(item), minimal = {};
|
|
122
|
+
for (const field of ['outcome', 'rawRecordDigest', 'role', 'origin', 'expectedContextDigest', 'findingIds']) {
|
|
123
|
+
if (!fields[field] || !('value' in fields[field])) throw new TypeError('Unreadable negative');
|
|
124
|
+
minimal[field] = fields[field].value;
|
|
125
|
+
}
|
|
126
|
+
recovered.push(...negativeObligations(validated(minimal)));
|
|
127
|
+
}
|
|
128
|
+
} catch { /* The outer UNKNOWN records the unreadable evidence. */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch { /* Hostile proxy: preserve everything already recovered. */ }
|
|
132
|
+
const unique = new Map();
|
|
133
|
+
for (const o of recovered) {
|
|
134
|
+
const key = canonical([o.findingId, o.originRecordDigest, o.originContextDigest, o.role, o.origin]);
|
|
135
|
+
unique.set(key, unique.has(key) ? preferObligation(o, unique.get(key)) : o);
|
|
136
|
+
}
|
|
137
|
+
return [...unique.values()].sort(sortBy('findingId'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function evaluateBoundShipEvidence(input) {
|
|
141
|
+
const result = emptyResult();
|
|
142
|
+
let unknown = false, blocked = false;
|
|
143
|
+
const diagnostic = (code, subject) => result.diagnostics.push({code, subject});
|
|
144
|
+
try {
|
|
145
|
+
const x = validated(input);
|
|
146
|
+
if (!plain(x)) throw new TypeError('Input schema');
|
|
147
|
+
const fields = ['expected', 'invocations', 'records', 'obligations', 'resolutions', 'tasks', 'readStatus'];
|
|
148
|
+
if (Object.keys(x).length !== fields.length || fields.some(k => !Object.hasOwn(x, k))) { unknown = true; diagnostic('INVALID_SCHEMA', 'input'); }
|
|
149
|
+
const lists = {};
|
|
150
|
+
for (const [key, check] of Object.entries({invocations: invocation, records: record, obligations: obligation, resolutions: resolution, tasks: task})) {
|
|
151
|
+
lists[key] = [];
|
|
152
|
+
if (!Array.isArray(x[key]) || x[key].length > 10000) { unknown = true; diagnostic('INVALID_BOUNDS', key); continue; }
|
|
153
|
+
for (const item of x[key]) {
|
|
154
|
+
if (check(item)) lists[key].push(item);
|
|
155
|
+
else { unknown = true; diagnostic('INVALID_SCHEMA', key); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const expectedValid = expectedSchema(x.expected), readValid = readSchema(x.readStatus);
|
|
159
|
+
if (!expectedValid || !readValid) { unknown = true; diagnostic('INVALID_SCHEMA', !expectedValid ? 'expected' : 'readStatus'); }
|
|
160
|
+
const e = expectedValid ? x.expected : null, read = readValid ? x.readStatus : null;
|
|
161
|
+
let contextValid = false;
|
|
162
|
+
if (e) {
|
|
163
|
+
result.contextDigest = stableDigest(e);
|
|
164
|
+
contextValid = e.source.clean && e.requirementsDigest === stableDigest(e.requiredReviewers) && ['qa-engineer', 'security-officer'].every(r => e.requiredReviewers.some(q => q.role === r)) && new Set(e.requiredReviewers.map(r => r.role)).size === e.requiredReviewers.length;
|
|
165
|
+
if (!contextValid) { unknown = true; diagnostic('INVALID_CONTEXT', 'expected'); }
|
|
166
|
+
}
|
|
167
|
+
if (read && ['context', 'assignments', 'journals', 'tasks', 'policy', 'origins'].some(k => read[k] !== 'COMPLETE')) { unknown = true; diagnostic('INCOMPLETE_READ', 'readStatus'); }
|
|
168
|
+
const deduplicate = (items, key) => {
|
|
169
|
+
const map = new Map(), conflicts = new Set();
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
const old = map.get(item[key]);
|
|
172
|
+
if (old && canonical(old) !== canonical(item)) { blocked = true; conflicts.add(item[key]); diagnostic('IDENTITY_CONFLICT', item[key]); }
|
|
173
|
+
else map.set(item[key], item);
|
|
174
|
+
}
|
|
175
|
+
for (const key of conflicts) map.delete(key);
|
|
176
|
+
return map;
|
|
177
|
+
};
|
|
178
|
+
const invs = deduplicate(lists.invocations, 'reviewerInvocationId');
|
|
179
|
+
const records = deduplicate(lists.records, 'recordId');
|
|
180
|
+
const resolutions = deduplicate(lists.resolutions, 'resolutionId');
|
|
181
|
+
const validPass = new Map();
|
|
182
|
+
if (contextValid && read) for (const r of records.values()) {
|
|
183
|
+
const required = e.requiredReviewers.find(q => q.role === r.role), inv = invs.get(r.reviewerInvocationId);
|
|
184
|
+
if (!required || !inv || r.outcome !== 'PASS' || r.findingIds.length || inv.revoked || inv.role !== required.role || inv.expectedContextDigest !== result.contextDigest || r.expectedContextDigest !== result.contextDigest || inv.taskId !== e.taskId || inv.runId !== e.runId || inv.issuedAtMs > read.nowMs || read.nowMs >= inv.expiresAtMs || r.startedAtMs < inv.issuedAtMs || r.startedAtMs > r.finishedAtMs || r.finishedAtMs >= inv.expiresAtMs || r.finishedAtMs > read.nowMs || r.authority !== 'CONTROLLED_REVIEW' || r.releaseEligibility !== 'SOURCE_GATE_ONLY' || r.evidenceClass !== e.evidenceClass) continue;
|
|
185
|
+
if (required.independentOf.some(a => a === inv.reviewerActorId || !inv.independentOf.includes(a)) || inv.independentOf.includes(inv.reviewerActorId)) continue;
|
|
186
|
+
if (canonical([...new Set(inv.requiredChecks)].sort(cpOrder)) !== canonical([...required.requiredChecks].sort(cpOrder))) continue;
|
|
187
|
+
if (new Set(r.checkEvidence.map(c => c.checkId)).size !== r.checkEvidence.length || !required.requiredChecks.every(c => r.checkEvidence.some(v => v.checkId === c && v.state === 'PASS'))) continue;
|
|
188
|
+
validPass.set(r.recordId, r);
|
|
189
|
+
}
|
|
190
|
+
if (e) result.mandatoryReviews = e.requiredReviewers.map(q => {
|
|
191
|
+
const recordIds = [...validPass.values()].filter(r => r.role === q.role).map(r => r.recordId).sort(cpOrder);
|
|
192
|
+
if (!recordIds.length) { blocked = true; diagnostic('MISSING_REVIEW', q.role); }
|
|
193
|
+
return {role: q.role, satisfied: recordIds.length > 0, recordIds};
|
|
194
|
+
}).sort(sortBy('role'));
|
|
195
|
+
const obligations = new Map();
|
|
196
|
+
const addObligation = o => {
|
|
197
|
+
// Severity metadata cannot erase the identity of the original obligation.
|
|
198
|
+
const key = canonical([o.findingId, o.originRecordDigest, o.originContextDigest, o.role, o.origin]);
|
|
199
|
+
obligations.set(key, obligations.has(key) ? preferObligation(o, obligations.get(key)) : o);
|
|
200
|
+
};
|
|
201
|
+
for (const o of lists.obligations) addObligation(o);
|
|
202
|
+
// Harvest negatives before assignment/context filtering, even when other metadata is malformed.
|
|
203
|
+
if (Array.isArray(x.records) && x.records.length <= 10000) for (const r of x.records) {
|
|
204
|
+
if (!plain(r) || !negative(r.outcome)) continue;
|
|
205
|
+
if (!sha(r.rawRecordDigest) || !role(r.role) || !origin(r.origin) || !nullableSha(r.expectedContextDigest) || !ids(r.findingIds)) { unknown = true; diagnostic('UNREADABLE_NEGATIVE', 'records'); continue; }
|
|
206
|
+
for (const findingId of r.findingIds.length ? r.findingIds : [`record:${r.rawRecordDigest}`]) addObligation({findingId, originRecordDigest: r.rawRecordDigest, originContextDigest: r.expectedContextDigest, role: r.role, severity: 'UNKNOWN', category: 'review', origin: r.origin});
|
|
207
|
+
}
|
|
208
|
+
const targets = new Map();
|
|
209
|
+
const targetKey = o => canonical([o.findingId, o.originRecordDigest, o.originContextDigest]);
|
|
210
|
+
for (const o of obligations.values()) {
|
|
211
|
+
const key = targetKey(o), origins = targets.get(key) || new Set();
|
|
212
|
+
origins.add(canonical([o.role, o.origin]));
|
|
213
|
+
targets.set(key, origins);
|
|
214
|
+
}
|
|
215
|
+
for (const o of obligations.values()) {
|
|
216
|
+
// Resolution schema cannot distinguish these occurrences. Require a new,
|
|
217
|
+
// unambiguous identity rather than clearing multiple origins accidentally.
|
|
218
|
+
if (targets.get(targetKey(o)).size > 1) {
|
|
219
|
+
blocked = true;
|
|
220
|
+
diagnostic('AMBIGUOUS_FINDING_ORIGIN', o.findingId);
|
|
221
|
+
result.openObligations.push(o);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const matches = [...resolutions.values()].filter(r => r.findingId === o.findingId && r.originRecordDigest === o.originRecordDigest && r.originContextDigest === o.originContextDigest && r.targetContextDigest === result.contextDigest);
|
|
225
|
+
const unique = new Map(matches.map(r => [canonical(r), r]));
|
|
226
|
+
if (unique.size > 1) { blocked = true; diagnostic('RESOLUTION_CONFLICT', o.findingId); result.openObligations.push(o); continue; }
|
|
227
|
+
const r = matches[0], review = r && validPass.get(r.reviewRecordId), inv = r && invs.get(r.reviewerInvocationId);
|
|
228
|
+
let disposition = 'openObligations';
|
|
229
|
+
if (r && review && inv && review.reviewerInvocationId === inv.reviewerInvocationId && review.role === o.role && inv.role === o.role && inv.reviewerActorId !== r.implementerActorId && inv.independentOf.includes(r.implementerActorId) && r.fixCommit === e?.source.head) {
|
|
230
|
+
if (r.kind === 'TECHNICAL') disposition = 'resolvedObligations';
|
|
231
|
+
else if (e.evidenceClass === 'LOCAL_FIXTURE' && read?.trustedOwnerDecisions.some(d => d.authenticated && d.decisionId === r.ownerDecisionId && d.targetContextDigest === r.targetContextDigest && d.findingId === r.findingId && d.successorIdentity === r.successorIdentity && d.coverageDisposition === r.coverageDisposition)) disposition = 'supersededObligations';
|
|
232
|
+
}
|
|
233
|
+
result[disposition].push(o);
|
|
234
|
+
}
|
|
235
|
+
result.blockingTasks = lists.tasks.filter(t => ['blocked', 'failed', 'unverified', 'not_run', 'not-run'].includes(t.state)).sort(sortBy('id'));
|
|
236
|
+
if (result.openObligations.length || result.blockingTasks.length) blocked = true;
|
|
237
|
+
for (const key of ['openObligations', 'resolvedObligations', 'supersededObligations']) result[key].sort(sortBy('findingId'));
|
|
238
|
+
result.state = unknown ? 'UNKNOWN' : blocked ? 'BLOCKED' : 'PASS';
|
|
239
|
+
} catch {
|
|
240
|
+
// Invalid input is explicitly reported through diagnostic() below. Preserve
|
|
241
|
+
// recoverable negatives, never reinterpret an unreadable assessment as PASS.
|
|
242
|
+
result.state = 'UNKNOWN';
|
|
243
|
+
result.openObligations = recoverObligations(input);
|
|
244
|
+
result.resolvedObligations = [];
|
|
245
|
+
result.supersededObligations = [];
|
|
246
|
+
diagnostic('INVALID_INPUT', 'input');
|
|
247
|
+
}
|
|
248
|
+
result.diagnostics = [...new Map(result.diagnostics.map(d => [canonical(d), d])).values()].sort(sortBy('code'));
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const legacyRecord = x => object(x, {rawDigest: sha, outcome, role});
|
|
253
|
+
const legacyEntry = x => object(x, {ordinal: ms, rawDigest: sha, outcome, role, findingId: id});
|
|
254
|
+
const legacyJournal = x => object(x, {journalId: id, originId: id, entries: array(legacyEntry)}) && x.entries.every((r, ordinal) => r.ordinal === ordinal && r.findingId === `legacy:${stableDigest({originId: x.originId, journalId: x.journalId, ordinal, rawDigest: r.rawDigest})}`);
|
|
255
|
+
const journal = x => object(x, {journalId: id, originId: id, snapshotId: id, complete: literal(true), records: array(legacyRecord)});
|
|
256
|
+
export function normalizeLegacyOrigins(input) {
|
|
257
|
+
const out = {state: 'UNKNOWN', registry: {version: 'ship-origin-registry/v1', journals: []}, obligations: [], diagnostics: []};
|
|
258
|
+
try {
|
|
259
|
+
const x = validated(input);
|
|
260
|
+
if (!plain(x) || !Object.hasOwn(x, 'registry') || !Object.hasOwn(x, 'journals') || Object.keys(x).length !== 2) throw new TypeError('Input schema');
|
|
261
|
+
if (x.registry !== null) {
|
|
262
|
+
if (!object(x.registry, {version: literal('ship-origin-registry/v1'), journals: array(legacyJournal)}) || new Set(x.registry.journals.map(j => j.journalId)).size !== x.registry.journals.length) throw new TypeError('Registry schema');
|
|
263
|
+
out.registry = x.registry;
|
|
264
|
+
}
|
|
265
|
+
let invalid = !array(journal)(x.journals);
|
|
266
|
+
const incoming = invalid ? [] : x.journals;
|
|
267
|
+
if (new Set(incoming.map(j => j.journalId)).size !== incoming.length) invalid = true;
|
|
268
|
+
const prior = new Map(out.registry.journals.map(j => [j.journalId, j]));
|
|
269
|
+
const additions = new Map(prior);
|
|
270
|
+
for (const old of prior.values()) if (!incoming.some(j => j.journalId === old.journalId)) invalid = true;
|
|
271
|
+
for (const j of incoming) {
|
|
272
|
+
const old = prior.get(j.journalId);
|
|
273
|
+
if (old && (old.originId !== j.originId || j.records.length < old.entries.length || old.entries.some((r, ordinal) => canonical({rawDigest: r.rawDigest, outcome: r.outcome, role: r.role}) !== canonical(j.records[ordinal])))) { invalid = true; continue; }
|
|
274
|
+
additions.set(j.journalId, {journalId: j.journalId, originId: j.originId, entries: j.records.map((r, ordinal) => ({ordinal, ...r, findingId: `legacy:${stableDigest({originId: j.originId, journalId: j.journalId, ordinal, rawDigest: r.rawDigest})}`}))});
|
|
275
|
+
}
|
|
276
|
+
// An ambiguous capture never changes the last complete registry.
|
|
277
|
+
if (!invalid) out.registry = {version: 'ship-origin-registry/v1', journals: [...additions.values()].sort(sortBy('journalId'))};
|
|
278
|
+
out.state = invalid ? 'UNKNOWN' : 'PASS';
|
|
279
|
+
if (invalid) out.diagnostics.push({code: 'LEGACY_PREFIX_UNKNOWN', subject: 'journals'});
|
|
280
|
+
} catch {
|
|
281
|
+
// Keep the last valid prefix even if a new snapshot contains hostile data.
|
|
282
|
+
try {
|
|
283
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, 'registry');
|
|
284
|
+
if (descriptor && 'value' in descriptor) {
|
|
285
|
+
const prior = validated(descriptor.value);
|
|
286
|
+
if (object(prior, {version: literal('ship-origin-registry/v1'), journals: array(legacyJournal)}) && new Set(prior.journals.map(j => j.journalId)).size === prior.journals.length) out.registry = prior;
|
|
287
|
+
}
|
|
288
|
+
} catch { /* Invalid registry is not treated as an empty authoritative prefix. */ }
|
|
289
|
+
out.diagnostics.push({code: 'INVALID_INPUT', subject: 'legacy'});
|
|
290
|
+
}
|
|
291
|
+
for (const j of out.registry.journals) for (const r of j.entries) if (negative(r.outcome)) out.obligations.push({findingId: r.findingId, originRecordDigest: r.rawDigest, originContextDigest: null, role: r.role, severity: 'UNKNOWN', category: 'legacy', origin: {journalId: j.journalId, ordinal: r.ordinal, rawDigest: r.rawDigest}});
|
|
292
|
+
out.obligations.sort(sortBy('findingId'));
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
@@ -86,17 +86,31 @@ export function acquireProjectLease(root, { store = workTaskStore(), reuseToken
|
|
|
86
86
|
root = canonical(root); ensureStore(root, store);
|
|
87
87
|
const dir = join(store, 'leases'); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
88
88
|
const lock = join(dir, digest(root) + '.lock');
|
|
89
|
+
const ownershipAssertion = (token, ownerPid) => {
|
|
90
|
+
const initialLock = lstatSync(lock), initialOwner = lstatSync(join(lock, 'owner.json'));
|
|
91
|
+
return () => {
|
|
92
|
+
const ls = lstatSync(lock), os = lstatSync(join(lock, 'owner.json'));
|
|
93
|
+
if (!ls.isDirectory() || !os.isFile() || ls.isSymbolicLink() || os.isSymbolicLink()
|
|
94
|
+
|| (ls.mode & 0o077) || (os.mode & 0o077)
|
|
95
|
+
|| ls.uid !== process.getuid() || os.uid !== process.getuid()
|
|
96
|
+
|| ls.ino !== initialLock.ino || os.ino !== initialOwner.ino) throw Error('project lease ownership changed');
|
|
97
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
98
|
+
if (owner.token !== token || owner.pid !== ownerPid || owner.projectId !== projectIdentity(root)
|
|
99
|
+
|| ownerPid !== (reuseToken ? process.ppid : process.pid)) throw Error('project lease ownership changed');
|
|
100
|
+
return true;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
89
103
|
if (reuseToken) {
|
|
90
104
|
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
91
105
|
if (owner.token !== reuseToken || owner.pid !== process.ppid || owner.projectId !== projectIdentity(root)) throw Error('invalid project lease handoff');
|
|
92
|
-
return { token: owner.token, release() {} };
|
|
106
|
+
return { token: owner.token, assertOwned: ownershipAssertion(owner.token, owner.pid), release() {} };
|
|
93
107
|
}
|
|
94
108
|
try { mkdirSync(lock, { mode: 0o700 }); }
|
|
95
109
|
catch (e) { if (e.code === 'EEXIST') throw Error('project execution is already owned; inspect its lease before recovery'); throw e; }
|
|
96
110
|
const token = randomUUID();
|
|
97
111
|
try { writeFileSync(join(lock, 'owner.json'), JSON.stringify({ token, pid: process.pid, projectId: projectIdentity(root), acquiredAt: new Date().toISOString() }), { mode: 0o600 }); }
|
|
98
112
|
catch (e) { rmSync(lock, { recursive: true }); throw e; }
|
|
99
|
-
return { token, release() {
|
|
113
|
+
return { token, assertOwned: ownershipAssertion(token, process.pid), release() {
|
|
100
114
|
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
101
115
|
if (owner.token !== token || owner.pid !== process.pid) throw Error('project lease ownership changed');
|
|
102
116
|
rmSync(lock, { recursive: true });
|
|
@@ -206,6 +206,39 @@ next = ["devops"]
|
|
|
206
206
|
on = ["DEPLOYED", "DONE"]
|
|
207
207
|
next = ["l3-support"]
|
|
208
208
|
|
|
209
|
+
# Client artifacts — a web frontend bundle, an iOS/Android build, an OTA update —
|
|
210
|
+
# are not a running service: no migration, no canary by error rate, a rollback
|
|
211
|
+
# that re-aliases a deployment or halts a store rollout. devops recognises them
|
|
212
|
+
# first thing and hands them over with this verdict instead of deploying.
|
|
213
|
+
#
|
|
214
|
+
# The edges INTO devops are untouched on purpose: the gate:ship floor and the
|
|
215
|
+
# controller probes both assert reviewers → devops. Branching after devops keeps
|
|
216
|
+
# every existing gate where it is.
|
|
217
|
+
[transitions.devops.CLIENT_RELEASE]
|
|
218
|
+
on = ["CLIENT_RELEASE"]
|
|
219
|
+
next = ["release-manager"]
|
|
220
|
+
|
|
221
|
+
# release-manager runs twice with gate:release between the runs.
|
|
222
|
+
# run 1 → STAGED: built from the CI-green commit, on preview / internal track
|
|
223
|
+
# e2e-test-engineer tests THAT artifact → ARTIFACT_PASSED → gate:release
|
|
224
|
+
# run 2 → RELEASED: promoted / submitted, landed, receipt written, old builds cleaned
|
|
225
|
+
# HANDED-OFF: a store submission was prepared and the operator presses the button
|
|
226
|
+
# (`store-submit: hand-off`, the default) — nothing follows automatically.
|
|
227
|
+
[transitions.release-manager]
|
|
228
|
+
on = ["RELEASED", "DONE"]
|
|
229
|
+
produces = ["release"]
|
|
230
|
+
next = ["l3-support"]
|
|
231
|
+
|
|
232
|
+
[transitions.release-manager.STAGED]
|
|
233
|
+
on = ["STAGED"]
|
|
234
|
+
produces = ["release"]
|
|
235
|
+
next = ["e2e-test-engineer"]
|
|
236
|
+
|
|
237
|
+
[transitions.release-manager.HANDED-OFF]
|
|
238
|
+
on = ["HANDED-OFF"]
|
|
239
|
+
produces = ["release"]
|
|
240
|
+
next = []
|
|
241
|
+
|
|
209
242
|
[transitions.l3-support]
|
|
210
243
|
on = ["OK", "DONE"]
|
|
211
244
|
next = []
|
|
@@ -299,6 +332,14 @@ next = ["code-reviewer"]
|
|
|
299
332
|
on = ["PASSED", "DONE"]
|
|
300
333
|
next = ["devops"]
|
|
301
334
|
|
|
335
|
+
# The artifact release-manager staged passed its E2E. The next step reaches users
|
|
336
|
+
# — production promote, store submission, production OTA — so a human decides
|
|
337
|
+
# here, at every approval level (IRREVERSIBLE_FLOOR in approval-level.mjs).
|
|
338
|
+
[transitions.e2e-test-engineer.ARTIFACT_PASSED]
|
|
339
|
+
on = ["ARTIFACT_PASSED"]
|
|
340
|
+
gate = ["gate:release"]
|
|
341
|
+
next = ["release-manager"]
|
|
342
|
+
|
|
302
343
|
# Data import is the one edge in this map that destroys evidence.
|
|
303
344
|
#
|
|
304
345
|
# The agent's own prompt says "Rollback destroys post-import work", and its human
|
package/package.json
CHANGED