great-cto 3.76.1 → 3.78.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.
@@ -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/dist/main.js CHANGED
@@ -815,9 +815,11 @@ ${bold("Board:")}
815
815
  ${bold("Operator console (the second surface — invite-only, hostable):")}
816
816
  great-cto console Serve ONLY the operator console (no dev board)
817
817
  great-cto console --port 8788 Different port
818
- great-cto console --bind 0.0.0.0 Reachable beyond this machine (tunnel/hosting);
819
- operators sign in via invite links. Only names you
820
- list are answered: GREAT_CTO_ALLOWED_HOSTS=console.example.com
818
+ great-cto console --bind 0.0.0.0 Reachable beyond this machine (tunnel/hosting).
819
+ There is no login: put authentication in front and set
820
+ GREAT_CTO_BOARD_BEHIND_AUTH=1, or the server refuses to start.
821
+ Only names you list are answered:
822
+ GREAT_CTO_ALLOWED_HOSTS=console.example.com
821
823
 
822
824
  ${bold("Telemetry (anonymous, opt-IN — OFF by default):")}
823
825
  great-cto telemetry status Show state + endpoint + your anon_id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.76.1",
3
+ "version": "3.78.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",