great-cto 3.70.1 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/agents/architect.md +1 -1
  4. package/board/agents/db-migration-reviewer.md +3 -2
  5. package/board/agents/design-advisor.md +1 -1
  6. package/board/agents/devops.md +1 -1
  7. package/board/agents/growth-engineer.md +1 -1
  8. package/board/agents/knowledge-extractor.md +1 -1
  9. package/board/agents/l3-support.md +1 -1
  10. package/board/agents/performance-engineer.md +1 -1
  11. package/board/agents/pm.md +5 -1
  12. package/board/agents/qa-engineer.md +4 -4
  13. package/board/agents/security-officer.md +2 -2
  14. package/board/agents/smart-contract-auditor.md +1 -1
  15. package/board/packages/board/lib/routes.mjs +14 -1
  16. package/board/packages/board/lib/work.mjs +34 -3
  17. package/board/packages/board/public/assets/work-view.js +146 -18
  18. package/board/scripts/codex-pipeline.mjs +2 -0
  19. package/board/scripts/lib/approval-level.mjs +4 -2
  20. package/board/scripts/lib/claude-exec.mjs +49 -6
  21. package/board/scripts/lib/codex-exec.mjs +42 -18
  22. package/board/scripts/lib/codex-host-state.mjs +18 -2
  23. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  24. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  25. package/board/scripts/lib/host-graph-events.mjs +157 -0
  26. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  27. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  28. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  29. package/board/scripts/work-task.mjs +17 -4
  30. package/board/shared/pipeline.toml +7 -1
  31. package/dist/adapt.js +10 -21
  32. package/dist/codex.js +4 -15
  33. package/dist/companion.js +6 -122
  34. package/dist/installer.js +4 -72
  35. package/dist/main.js +44 -117
  36. package/dist/release-activation.mjs +561 -0
  37. package/dist/release-archive.mjs +165 -0
  38. package/dist/release-trust.mjs +514 -0
  39. package/dist/release-update.mjs +303 -0
  40. package/dist/self-upgrade.js +10 -39
  41. package/dist/update-check.js +1 -0
  42. package/dist/upgrade.js +5 -120
  43. package/package.json +1 -1
  44. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  45. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  46. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  47. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  48. package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
@@ -0,0 +1,210 @@
1
+ /** Private observations only. This journal cannot dispatch, approve or verify work. */
2
+ import { createHash } from 'node:crypto';
3
+ import { constants, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+
7
+ export const HOST_GRAPH_LIMITS = Object.freeze({ events: 4096, bytes: 4 * 1024 * 1024, eventBytes: 4096, page: 200 });
8
+ export const hostGraphStore = () => process.env.GREAT_CTO_HOST_GRAPHS_DIR || join(homedir(), '.great_cto', 'host-graphs');
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+ const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
11
+ const identity = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value);
12
+ const hosts = new Set(['codex', 'claude-code']), modes = new Set(['controlled', 'native']);
13
+ const kinds = new Set(['session', 'turn', 'tool', 'child', 'error']);
14
+ const phases = new Set(['started', 'completed', 'failed', 'stopped', 'unknown']);
15
+ const outcomes = new Set(['unknown', 'succeeded', 'failed', 'cancelled']);
16
+ const tools = new Set(['shell', 'read', 'write', 'search', 'mcp', 'agent', 'other']);
17
+ export const HOST_GRAPH_DIAGNOSTICS = Object.freeze(['unsupported-host-or-mode', 'invalid-trusted-binding', 'invalid-event', 'unsupported-event-shape', 'invalid-trusted-parents', 'missing-explicit-parent', 'missing-source-event-identity', 'source-version-unverified', 'observer-listener-failure', 'observer-transport-failure']);
18
+ const diagnosticCodes = new Set(HOST_GRAPH_DIAGNOSTICS);
19
+ const date = value => typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value));
20
+ const safeVersion = value => value === null || value === undefined || (typeof value === 'string' && /^\d{1,5}(?:\.\d{1,5}){0,3}(?:[-+][0-9A-Za-z.-]{1,32})?$/.test(value) && value.length <= 80);
21
+
22
+ function bindingIdentity(binding) {
23
+ if (!binding || typeof binding.root !== 'string' || !identity(binding.runId) || !identity(binding.sessionId)
24
+ || (binding.attemptId != null && !identity(binding.attemptId)) || (binding.host != null && !hosts.has(binding.host))) throw Error('invalid journal binding');
25
+ const root = realpathSync(binding.root), projectId = 'project:' + hash(root);
26
+ if (binding.projectId !== projectId) throw Error('journal project binding does not match root');
27
+ // A journal spans retries in one session, while attempts remain event-specific.
28
+ return { root, projectId, runId: binding.runId, sessionId: binding.sessionId, host: binding.host ?? null,
29
+ key: hash(JSON.stringify([projectId, binding.runId, binding.sessionId, ...(binding.host ? [binding.host] : [])])) };
30
+ }
31
+ function secureAncestors(path) {
32
+ const parents = [];
33
+ for (let cursor = resolve(path); ; cursor = dirname(cursor)) {
34
+ parents.push(cursor); if (dirname(cursor) === cursor) break;
35
+ }
36
+ for (const parent of parents.reverse()) {
37
+ try { if (lstatSync(parent).isSymbolicLink()) throw Error('journal path cannot contain symlinks'); }
38
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
39
+ }
40
+ }
41
+ function context(binding, store, create = false) {
42
+ const bound = bindingIdentity(binding), location = resolve(store);
43
+ secureAncestors(location);
44
+ const rel = relative(bound.root, location);
45
+ if (!rel || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel))) throw Error('journal must be outside the worker project');
46
+ if (create) mkdirSync(location, { recursive: true, mode: 0o700 });
47
+ if (existsSync(location)) {
48
+ const info = lstatSync(location);
49
+ if (!info.isDirectory() || (info.mode & 0o077) || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('journal store must be owner-only 0700');
50
+ }
51
+ return { ...bound, store: location, file: join(location, bound.key + '.jsonl') };
52
+ }
53
+ function normalize(event, binding, expected) {
54
+ if (!event || event.schemaVersion !== 1 || !digest(event.id) || (event.sourceEventId != null && !digest(event.sourceEventId))
55
+ || !hosts.has(event.host) || (expected.host != null && event.host !== expected.host) || !modes.has(event.mode) || !kinds.has(event.kind) || !phases.has(event.phase)
56
+ || !outcomes.has(event.outcome) || (event.callId != null && !digest(event.callId)) || event.sessionId !== expected.sessionId
57
+ || (event.projectId != null && event.projectId !== expected.projectId) || (event.runId != null && event.runId !== expected.runId)
58
+ || (event.attemptId != null && !identity(event.attemptId)) || (binding.attemptId != null && event.attemptId !== binding.attemptId)
59
+ || (event.sequence != null && (!Number.isSafeInteger(event.sequence) || event.sequence < 0))
60
+ || !Array.isArray(event.parentIds) || event.parentIds.length > 16 || event.parentIds.some(id => !digest(id) || id === event.id)
61
+ || (event.diagnosticCodes != null && (event.kind !== 'error' || !Array.isArray(event.diagnosticCodes) || event.diagnosticCodes.length > 16 || event.diagnosticCodes.some(code => !diagnosticCodes.has(code))))
62
+ || !date(event.observedAt) || (event.occurredAt != null && !date(event.occurredAt))
63
+ || (event.toolClass != null && !tools.has(event.toolClass)) || !safeVersion(event.sourceVersion)) throw Error('invalid normalized journal event');
64
+ const value = { schemaVersion: 1, id: event.id, projectId: expected.projectId, runId: expected.runId,
65
+ sourceEventId: event.sourceEventId ?? null, sequence: event.sequence ?? null, host: event.host, mode: event.mode,
66
+ kind: event.kind, phase: event.phase, outcome: event.outcome, callId: event.callId ?? null, sessionId: expected.sessionId,
67
+ attemptId: event.attemptId ?? null, parentIds: [...new Set(event.parentIds)],
68
+ occurredAt: event.occurredAt ?? null, observedAt: event.observedAt, toolClass: event.toolClass ?? null,
69
+ sourceVersion: event.sourceVersion ?? null, ...(event.diagnosticCodes ? { diagnosticCodes: [...new Set(event.diagnosticCodes)] } : {}) };
70
+ if (Buffer.byteLength(JSON.stringify(value)) > HOST_GRAPH_LIMITS.eventBytes) throw Error('journal event exceeds size limit');
71
+ return value;
72
+ }
73
+ const comparable = event => { const { observedAt, ...rest } = event; return JSON.stringify(rest); };
74
+ function descriptor(file, flags) {
75
+ const fd = openSync(file, flags | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0), 0o600);
76
+ try {
77
+ const info = fstatSync(fd);
78
+ if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o077) || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('journal file must be owner-only 0600 with one link');
79
+ return { fd, info };
80
+ } catch (error) { closeSync(fd); throw error; }
81
+ }
82
+ function scan(ctx, binding) {
83
+ let opened;
84
+ try { opened = descriptor(ctx.file, constants.O_RDONLY); }
85
+ catch (error) { if (error.code === 'ENOENT') return { events: [], diagnostics: [], bytes: 0, tail: false, absent: true }; throw error; }
86
+ try {
87
+ if (opened.info.size > HOST_GRAPH_LIMITS.bytes) throw Error('journal exceeds byte quota');
88
+ const raw = readFileSync(opened.fd, 'utf8');
89
+ const lines = raw.split('\n'), tail = !!lines.at(-1), diagnostics = [], events = [], ids = new Map();
90
+ if (tail) diagnostics.push('truncated-tail');
91
+ for (const line of lines) {
92
+ if (!line) continue;
93
+ if (Buffer.byteLength(line) > HOST_GRAPH_LIMITS.eventBytes) { diagnostics.push('oversized-record'); continue; }
94
+ try {
95
+ const event = normalize(JSON.parse(line), { ...binding, attemptId: null }, ctx);
96
+ if (ids.has(event.id)) {
97
+ if (comparable(ids.get(event.id)) !== comparable(event)) diagnostics.push('conflicting-event-id');
98
+ continue;
99
+ }
100
+ if (events.length >= HOST_GRAPH_LIMITS.events) { diagnostics.push('event-quota'); break; }
101
+ ids.set(event.id, event); events.push(event); diagnostics.push(...(event.diagnosticCodes || []));
102
+ } catch { diagnostics.push('malformed-record'); }
103
+ }
104
+ if (events.length >= HOST_GRAPH_LIMITS.events) diagnostics.push('event-quota');
105
+ return { events, diagnostics: [...new Set(diagnostics)], bytes: Buffer.byteLength(raw), tail, absent: false };
106
+ } finally { closeSync(opened.fd); }
107
+ }
108
+ const reasonFor = error => typeof error?.code === 'string' && /^E[A-Z0-9_]+$/.test(error.code)
109
+ ? `journal I/O unavailable (${error.code})` : String(error?.message || 'journal unavailable').slice(0, 180);
110
+
111
+ const LOSS_CODES = new Set(['writer-contention', 'event-conflict', 'event-quota', 'byte-quota', 'append-failure', 'writer-cleanup-failure']);
112
+ function lossDiagnostics(ctx) {
113
+ let opened;
114
+ try {
115
+ opened = descriptor(ctx.file + '.incomplete', constants.O_RDONLY);
116
+ if (opened.info.size > 512) return ['invalid-loss-marker'];
117
+ const marker = JSON.parse(readFileSync(opened.fd, 'utf8'));
118
+ return marker?.schemaVersion === 1 && marker.binding === ctx.key && LOSS_CODES.has(marker.code)
119
+ ? [marker.code] : ['invalid-loss-marker'];
120
+ } catch (error) { return error.code === 'ENOENT' ? [] : ['invalid-loss-marker']; }
121
+ finally { if (opened) closeSync(opened.fd); }
122
+ }
123
+ function markIncomplete(ctx, code) {
124
+ if (!ctx || !LOSS_CODES.has(code)) return false;
125
+ let fd;
126
+ try {
127
+ // One immutable marker is enough to establish loss, even when the journal lock is held.
128
+ ({ fd } = descriptor(ctx.file + '.incomplete', constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL));
129
+ const bytes = Buffer.from(JSON.stringify({ schemaVersion: 1, binding: ctx.key, code }) + '\n');
130
+ let offset = 0;
131
+ while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
132
+ fsyncSync(fd);
133
+ const dir = openSync(ctx.store, constants.O_RDONLY); try { fsyncSync(dir); } finally { closeSync(dir); }
134
+ return true;
135
+ } catch (error) { return error.code === 'EEXIST' && lossDiagnostics(ctx).some(c => LOSS_CODES.has(c)); }
136
+ finally { if (fd !== undefined) closeSync(fd); }
137
+ }
138
+ function lost(ctx, code, reason) {
139
+ return { state: 'degraded', reason, lossMarker: markIncomplete(ctx, code) ? 'recorded' : 'unavailable' };
140
+ }
141
+
142
+ /** A lock failure never steals a writer lease or changes execution state. */
143
+ export function appendHostGraphEvent(binding, event, { store = hostGraphStore() } = {}) {
144
+ let ctx, locked = false;
145
+ try {
146
+ ctx = context(binding, store, true);
147
+ const value = normalize(event, binding, ctx), lock = ctx.file + '.lock';
148
+ try { mkdirSync(lock, { mode: 0o700 }); locked = true; }
149
+ catch (error) { if (error.code === 'EEXIST') return lost(ctx, 'writer-contention', 'journal writer is busy; delivery is incomplete'); throw error; }
150
+ const current = scan(ctx, binding), previous = current.events.find(e => e.id === value.id);
151
+ if (previous) return comparable(previous) === comparable(value) ? { state: 'duplicate', reason: null }
152
+ : lost(ctx, 'event-conflict', 'conflicting event identity; history is unchanged');
153
+ if (current.events.length >= HOST_GRAPH_LIMITS.events) return lost(ctx, 'event-quota', 'journal event quota reached; history is incomplete');
154
+ const line = (current.tail ? '\n' : '') + JSON.stringify(value) + '\n';
155
+ if (current.bytes + Buffer.byteLength(line) > HOST_GRAPH_LIMITS.bytes) return lost(ctx, 'byte-quota', 'journal byte quota reached; history is incomplete');
156
+ const { fd } = descriptor(ctx.file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT);
157
+ try {
158
+ const bytes = Buffer.from(line); let offset = 0;
159
+ while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
160
+ fsyncSync(fd);
161
+ } finally { closeSync(fd); }
162
+ if (current.absent) { const dir = openSync(ctx.store, constants.O_RDONLY); try { fsyncSync(dir); } finally { closeSync(dir); } }
163
+ return { state: 'recorded', reason: current.diagnostics.length ? 'earlier journal damage remains visible' : null };
164
+ } catch (error) { return lost(ctx, 'append-failure', reasonFor(error)); }
165
+ finally { if (locked) { try { rmSync(ctx.file + '.lock', { recursive: true, force: true }); } catch { markIncomplete(ctx, 'writer-cleanup-failure'); } } }
166
+ }
167
+ function coverage(events, diagnostics, capabilities) {
168
+ const support = value => value === true || value === 'supported' ? true : value === false || value === 'unsupported' ? false : null;
169
+ const item = (value, reason) => { const supported = support(value); return { supported, state: supported === false || !events.length ? 'unavailable' : 'partial', reason }; };
170
+ // Even a terminal event cannot attest that the host delivered every event.
171
+ return {
172
+ lifecycle: item(capabilities?.lifecycle ?? null, diagnostics.length ? 'Observation gaps or damage are recorded.' : 'Host delivery completeness is not attested.'),
173
+ parentChild: item(capabilities?.parentChild ?? null, 'Only explicit parent identities establish relationships.'),
174
+ tools: item(capabilities?.tools ?? null, 'Tool observations do not establish complete host coverage.'),
175
+ verification: item(false, 'Verifier authority comes from controller receipts, never host observations.'),
176
+ gates: item(false, 'Gate authority comes from validated controller decisions, never host observations.'),
177
+ artifacts: item(false, 'Publication and deployment require separately bound evidence.'),
178
+ };
179
+ }
180
+ export function readHostGraph(binding, { store = hostGraphStore(), cursor = null, limit = HOST_GRAPH_LIMITS.page, capabilities = null } = {}) {
181
+ let events = [], diagnostics = [];
182
+ try {
183
+ const ctx = context(binding, store), result = scan(ctx, binding); events = result.events; diagnostics = [...result.diagnostics, ...lossDiagnostics(ctx)];
184
+ let offset = 0;
185
+ if (cursor != null) {
186
+ if (typeof cursor !== 'string' || !/^[a-f0-9]{64}:[0-9]{1,5}$/.test(cursor) || cursor.split(':')[0] !== ctx.key) throw Error('invalid journal cursor');
187
+ offset = Number(cursor.split(':')[1]); if (offset > events.length) throw Error('journal cursor is outside history');
188
+ }
189
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > HOST_GRAPH_LIMITS.page) throw Error('invalid journal page limit');
190
+ const ids = new Set(events.map(e => e.id));
191
+ if (events.some(e => e.parentIds.some(id => !ids.has(id)))) diagnostics.push('missing-parent');
192
+ const sequences = new Map();
193
+ for (const e of events.filter(e => e.sequence != null)) {
194
+ const source = `${e.host}:${e.attemptId || ''}`, seen = sequences.get(source) || new Set(); seen.add(e.sequence); sequences.set(source, seen);
195
+ }
196
+ for (const seen of sequences.values()) {
197
+ const sorted = [...seen].sort((a, b) => a - b);
198
+ if (sorted.some((n, i) => i > 0 && n !== sorted[i - 1] + 1)) diagnostics.push('source-sequence-gap');
199
+ }
200
+ diagnostics = [...new Set(diagnostics)];
201
+ const page = events.slice(offset, offset + limit), next = offset + page.length;
202
+ return { state: events.length ? 'recorded' : 'unavailable', events: page, total: events.length,
203
+ cursor: next < events.length ? `${ctx.key}:${next}` : null, health: diagnostics.length ? 'degraded' : result.absent ? 'absent' : 'ok',
204
+ reason: diagnostics.length ? 'Journal observations are incomplete.' : result.absent ? 'No host observation journal is recorded.' : null,
205
+ diagnostics, coverage: coverage(events, diagnostics, capabilities) };
206
+ } catch (error) {
207
+ return { state: 'unavailable', events: [], total: 0, cursor: null, health: 'degraded', reason: reasonFor(error),
208
+ diagnostics: ['unreadable-journal'], coverage: coverage([], ['unreadable-journal'], capabilities) };
209
+ }
210
+ }
@@ -0,0 +1,61 @@
1
+ /** Local observation only. Never owns execution, gate decisions or native sessions. */
2
+ import { createHash } from 'node:crypto';
3
+ import { normalizeHostEvents } from './host-graph-events.mjs';
4
+ import { appendHostGraphEvent, readHostGraph } from './host-graph-journal.mjs';
5
+ import { projectIdentity } from './work-tasks.mjs';
6
+
7
+ export function graphBinding({ root, runId, sessionId, attemptId = null, host = null }) {
8
+ return { root, projectId: projectIdentity(root), runId, sessionId, attemptId, ...(host ? { host } : {}) };
9
+ }
10
+ export function recordHostGraphGap(binding, host, mode, diagnosticCodes, { store } = {}) {
11
+ const codes = [...new Set(diagnosticCodes)].sort();
12
+ const id = createHash('sha256').update(JSON.stringify([binding.projectId, binding.runId, binding.sessionId, binding.attemptId, codes])).digest('hex');
13
+ return appendHostGraphEvent(binding, { schemaVersion: 1, id, host, mode, kind: 'error', phase: 'unknown', outcome: 'unknown',
14
+ sessionId: binding.sessionId, attemptId: binding.attemptId || null, parentIds: [], observedAt: new Date().toISOString(), diagnosticCodes: codes }, { store });
15
+ }
16
+ export function observeHostGraph({ binding, host, mode, sourceVersion, event, store }) {
17
+ try {
18
+ const normalized = normalizeHostEvents({ host, mode, sourceVersion, event, binding });
19
+ const results = normalized.events.map(e => appendHostGraphEvent(binding, e, { store }));
20
+ if (normalized.diagnostics.length) {
21
+ results.push(recordHostGraphGap(binding, host, mode, normalized.diagnostics, { store }));
22
+ }
23
+ const degraded = normalized.diagnostics.length || results.some(r => r.state === 'degraded');
24
+ return { state: degraded ? 'degraded' : results.length ? 'recorded' : 'ignored',
25
+ recorded: results.filter(r => r.state === 'recorded').length,
26
+ // Do not forward parser exceptions or source payloads into public state.
27
+ reason: degraded ? 'Host observations have gaps or unavailable storage.' : null };
28
+ } catch { return { state: 'degraded', recorded: 0, reason: 'Host observation could not be recorded.' }; }
29
+ }
30
+
31
+ /** Explicit call identity links start/end; ordering alone never creates an edge. */
32
+ export function projectHostObservations(binding, options = {}) {
33
+ try {
34
+ const read = readHostGraph(binding, options), events = read.events || [];
35
+ const nodes = events.map(e => ({ id: e.id, kind: e.kind, role: e.toolClass || e.kind,
36
+ host: e.host, sourceVersion: e.sourceVersion || null, phase: e.phase, outcome: e.outcome,
37
+ status: e.outcome === 'unknown' ? e.phase : e.outcome,
38
+ at: e.occurredAt || e.observedAt, attemptId: e.attemptId, source: 'host-observation' }));
39
+ const ids = new Set(nodes.map(n => n.id)), edges = [], starts = new Map(), ends = new Map();
40
+ const callKey = e => JSON.stringify([e.host, e.sessionId, e.attemptId, e.callId]);
41
+ for (const e of events) {
42
+ for (const parent of e.parentIds || []) if (ids.has(parent) && parent !== e.id) edges.push({ from: parent, to: e.id, kind: 'observed-parent' });
43
+ if (e.callId) { const map = e.phase === 'started' ? starts : ends; const key = callKey(e);
44
+ map.set(key, map.has(key) ? null : e.id); }
45
+ }
46
+ for (const e of events) if (e.callId && e.phase !== 'started' && starts.get(callKey(e)) && ends.get(callKey(e)) === e.id) {
47
+ const from = starts.get(callKey(e)); if (from !== e.id) edges.push({ from, to: e.id, kind: 'same-call' });
48
+ }
49
+ const times = events.map(e => Date.parse(e.observedAt)).filter(Number.isFinite);
50
+ return { binding: { runId: binding.runId, sessionId: binding.sessionId, host: binding.host || events[0]?.host || null }, state: nodes.length ? 'recorded' : 'unavailable', source: 'host-observation', complete: false,
51
+ truncated: !!read.cursor, nodes, edges, snapshots: [], diagnostics: (read.diagnostics || []).slice(0, 16),
52
+ observedInterval: times.length ? { from: new Date(Math.min(...times)).toISOString(), to: new Date(Math.max(...times)).toISOString() } : null, coverage: read.coverage, health: read.health,
53
+ cursor: read.cursor, total: read.total, reason: read.reason || 'Host observations do not establish controller transitions, delivery completeness or gate approval.' };
54
+ } catch (error) { return { state: 'unavailable', source: 'host-observation', complete: false, truncated: false,
55
+ nodes: [], edges: [], snapshots: [], health: 'degraded', reason: typeof error?.code === 'string' && /^E[A-Z0-9_]+$/.test(error.code)
56
+ ? `Host observation source is unreadable (${error.code}).` : 'Host observation source is unreadable.' }; }
57
+ }
58
+
59
+ export const bindingDigest = binding => createHash('sha256').update(JSON.stringify([
60
+ binding.projectId, binding.runId, binding.sessionId,
61
+ ])).digest('hex');
@@ -0,0 +1,38 @@
1
+ /** Provider-free CLI surface audit. Help text is not execution or hook-delivery proof. */
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ const hosts = new Set(['codex', 'claude-code']);
5
+ const commandOptions = Object.freeze({ encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024, windowsHide: true });
6
+ const hasFlag = (text, flag) => new RegExp(`(?:^|[\\s,\\[|])${flag}(?=[\\s,=\\]|]|$)`).test(text);
7
+ const sourceVersion = result => {
8
+ const text = String(result?.stdout || result?.stderr || '');
9
+ return text.match(/\b(\d{1,5}\.\d{1,5}\.\d{1,5}(?:[-+][a-zA-Z0-9.-]{1,32})?)\b/)?.[1] || null;
10
+ };
11
+ function command(run, bin, args) {
12
+ try { return run(bin, args, { ...commandOptions }); }
13
+ catch { return { status: null, error: { code: 'AUDIT_COMMAND_FAILED' } }; }
14
+ }
15
+
16
+ export function auditHostGraphRuntime({ host, bin = host === 'claude-code' ? 'claude' : 'codex', run = spawnSync } = {}) {
17
+ if (!hosts.has(host)) throw Error('unsupported graph host');
18
+ const result = { host, sourceVersion: null, state: 'unreadable', controlled: { streamEvents: null },
19
+ native: { hookEvents: null }, liveAcceptance: 'not-checked' };
20
+ const version = command(run, bin, ['--version']);
21
+ if (version?.error || version?.status !== 0) {
22
+ result.state = version?.error?.code === 'ENOENT' ? 'absent' : 'unreadable';
23
+ return result;
24
+ }
25
+ result.sourceVersion = sourceVersion(version);
26
+ const help = command(run, bin, host === 'codex' ? ['exec', '--help'] : ['--help']);
27
+ if (help?.error || help?.status !== 0) return result;
28
+ const text = String(help.stdout || '');
29
+ const stream = host === 'codex' ? hasFlag(text, '--json')
30
+ : hasFlag(text, '--output-format') && /\bstream-json\b/.test(text);
31
+ result.controlled.streamEvents = stream;
32
+ result.state = !result.sourceVersion ? 'unreadable' : stream ? 'available' : 'unsupported';
33
+ return result;
34
+ }
35
+
36
+ export function auditHostGraphRuntimes({ run = spawnSync, binaries = {} } = {}) {
37
+ return ['codex', 'claude-code'].map(host => auditHostGraphRuntime({ host, run, bin: binaries[host] || (host === 'codex' ? 'codex' : 'claude') }));
38
+ }
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /** Advanced operator contract behind `great-cto task work`; no new daily flow. */
3
+ import { auditHostGraphRuntimes } from './lib/host-graph-runtime.mjs';
3
4
  import { resolve } from 'node:path';
5
+ import { readSync } from 'node:fs';
6
+ import { bindNativeGraph, unbindNativeGraph, nativeGraphObservations, observeNativeGraphHook, readNativeGraphPage, enableNativeGraph, disableNativeGraph } from './lib/host-graph-bindings.mjs';
4
7
  import { listWorkTasks, readWorkTask } from './lib/work-tasks.mjs';
5
8
  import { approveWorkDecision, decisionCapabilities } from './lib/work-decisions.mjs';
6
9
  import { applyOutcome, readOutcomeEvidence } from './lib/work-outcomes.mjs';
@@ -8,14 +11,24 @@ import { measureWork, compareWork } from './lib/work-metrics.mjs';
8
11
  import { previewPublication, publishPublication } from './lib/work-publication.mjs';
9
12
  const args = process.argv.slice(2), action = args.shift();
10
13
  try {
11
- const supported = new Set(['--dir','--task','--decision','--revision','--operation','--evidence','--baseline','--base','--allow','--approval','--confirm']);
14
+ const supported = new Set(['--dir','--task','--decision','--revision','--operation','--evidence','--baseline','--base','--allow','--approval','--confirm','--host','--session','--source-version','--cursor','--limit']);
12
15
  const opts = {};
13
16
  for (let i = 0; i < args.length; i += 2) { if (!supported.has(args[i]) || !args[i+1] || args[i+1].startsWith('--') || opts[args[i]] !== undefined) throw Error('invalid or duplicate operator option'); opts[args[i]] = args[i+1]; }
14
- const perAction = { preview: ['--dir','--task','--base','--allow'], publish: ['--dir','--task','--base','--allow','--revision','--approval','--confirm'], metrics: ['--dir','--baseline'], decisions: ['--dir','--task'], approve: ['--dir','--task','--decision','--revision','--operation'], verify: ['--dir','--task','--revision','--operation','--evidence'], complete: ['--dir','--task','--revision','--operation'] };
17
+ const perAction = { 'graph-audit': [], 'graph-enable': ['--dir','--host','--source-version'], 'graph-disable': ['--dir','--host'], 'graph-bind': ['--dir','--host','--session','--source-version'], 'graph-unbind': ['--dir','--host','--session'], 'graph-read': ['--dir','--host','--session','--cursor','--limit'], 'graph-ingest': ['--dir','--host'], preview: ['--dir','--task','--base','--allow'], publish: ['--dir','--task','--base','--allow','--revision','--approval','--confirm','--host','--session','--source-version'], metrics: ['--dir','--baseline'], decisions: ['--dir','--task'], approve: ['--dir','--task','--decision','--revision','--operation'], verify: ['--dir','--task','--revision','--operation','--evidence'], complete: ['--dir','--task','--revision','--operation'] };
15
18
  if (perAction[action] && Object.keys(opts).some(k => !perAction[action].includes(k))) throw Error('option is not supported for this operation');
16
19
  const root = resolve(opts['--dir'] || '.'), taskId = opts['--task'];
17
20
  let result;
18
- if (['preview','publish'].includes(action)) {
21
+ if (action === 'graph-audit') result = auditHostGraphRuntimes();
22
+ else if (action === 'graph-enable' || action === 'graph-disable') result = (action === 'graph-enable' ? enableNativeGraph : disableNativeGraph)({ root, host: opts['--host'], sourceVersion: opts['--source-version'] || null });
23
+ else if (action === 'graph-bind' || action === 'graph-unbind') {
24
+ result = (action === 'graph-bind' ? bindNativeGraph : unbindNativeGraph)({ root, host: opts['--host'], sessionId: opts['--session'], sourceVersion: opts['--source-version'] || null });
25
+ } else if (action === 'graph-read') result = opts['--session'] ? readNativeGraphPage({ root, host: opts['--host'], sessionId: opts['--session'], cursor: opts['--cursor'] || null, limit: opts['--limit'] ? Number(opts['--limit']) : 200 }) : nativeGraphObservations(root);
26
+ else if (action === 'graph-ingest') {
27
+ const pieces = []; let size = 0, count; const buffer = Buffer.alloc(65536);
28
+ while ((count = readSync(0, buffer, 0, buffer.length, null)) > 0) { size += count; if (size > 1024 * 1024) throw Error('event input limit'); pieces.push(Buffer.from(buffer.subarray(0, count))); }
29
+ const raw = Buffer.concat(pieces).toString('utf8');
30
+ result = observeNativeGraphHook({ root, host: opts['--host'], event: JSON.parse(raw) });
31
+ } else if (['preview','publish'].includes(action)) {
19
32
  const request = { root, taskId, base: opts['--base'] || 'main', allow: opts['--allow'] ? opts['--allow'].split(',') : null };
20
33
  result = action === 'preview' ? previewPublication(request) : publishPublication({ ...request,
21
34
  expectedRevision: Number(opts['--revision']), approval: opts['--approval'], confirm: opts['--confirm'] });
@@ -26,6 +39,6 @@ try {
26
39
  } else if (action === 'decisions') result = decisionCapabilities(readWorkTask(taskId, { root }));
27
40
  else if (action === 'approve') result = approveWorkDecision({ root, taskId, decisionId: opts['--decision'], expectedRevision: Number(opts['--revision']), operationId: opts['--operation'] });
28
41
  else if (['verify','complete'].includes(action)) result = applyOutcome({ root, taskId, kind: action, expectedRevision: Number(opts['--revision']), operationId: opts['--operation'], evidenceFile: opts['--evidence'] });
29
- else throw Error('usage: great-cto task work decisions|approve|verify|complete|metrics|preview|publish --dir PATH [--task UUID --revision N --operation UUID --decision ID --evidence EXTERNAL_FILE --baseline EXTERNAL_FILE --base BRANCH --allow PATHS --approval DIGEST --confirm publish-draft-pr]');
42
+ else throw Error('usage: great-cto task work decisions|approve|verify|complete|metrics|preview|publish|graph-audit|graph-enable|graph-disable|graph-bind|graph-unbind|graph-ingest|graph-read --dir PATH [--task UUID --revision N --operation UUID --decision ID --evidence EXTERNAL_FILE --baseline EXTERNAL_FILE --base BRANCH --allow PATHS --approval DIGEST --confirm publish-draft-pr]');
30
43
  console.log(JSON.stringify(result, null, 2));
31
44
  } catch (e) { console.error('work-task: ' + e.message); process.exitCode = 2; }
@@ -303,7 +303,13 @@ next = ["qa-engineer"]
303
303
  on = ["DONE", "APPROVED"]
304
304
  # DESIGN-{slug}.md — the design + a11y contract
305
305
  produces = ["design"]
306
- next = ["senior-dev"]
306
+ # Into pm, not senior-dev. Both used to lead to senior-dev with no join, and the
307
+ # routing table ran design-advisor alongside pm, so each success opened the edge
308
+ # and senior-dev could start twice. A join cannot fix it: design-advisor runs only
309
+ # for UI features, and pm would wait for a stage that never starts. One path into
310
+ # senior-dev does; pm also plans the UI tasks against the finished contract — the
311
+ # order the Codex controller already runs (design-advisor-prebuild).
312
+ next = ["pm"]
307
313
 
308
314
  # Audits the Solidity/EVM contracts once they exist (web3, or /review --contracts).
309
315
  # PASS hands the audit to security-officer, which reads it before gate:ship; FAIL
package/dist/adapt.js CHANGED
@@ -325,7 +325,7 @@ dirty-commits: false
325
325
  // a path nothing reads is the exact defect this repository exists to delete: it
326
326
  // looked more finished than an absent one.
327
327
  /**
328
- * The config.toml fragment for Codex — only the part that runs.
328
+ * A comment-only Codex notice until verified no-fetch activation is supported.
329
329
  *
330
330
  * What this used to emit, and why each line is gone:
331
331
  *
@@ -340,10 +340,8 @@ dirty-commits: false
340
340
  * "full 57-agent routing" — we ship seventy, and
341
341
  * Codex routes none of them
342
342
  *
343
- * An MCP server is the one thing here Codex actually starts, so it is the one
344
- * thing left. The rest of the fragment is now a statement of what does NOT come
345
- * with it, taken from `codexInstallPlan` so the two cannot drift apart again —
346
- * which is precisely how they drifted the first time.
343
+ * ADR-045 also removes the mutable registry MCP recipe. Keep unsupported
344
+ * capability notes from codexInstallPlan, without enabling an executable route.
347
345
  */
348
346
  function getCodexConfigFragment(meta) {
349
347
  const plan = codexInstallPlan({ repoDir: "<path-to-great_cto>", codexOnPath: true });
@@ -351,11 +349,11 @@ function getCodexConfigFragment(meta) {
351
349
  return `# great_cto ⇄ Codex — generated by great-cto adapt
352
350
  # Generated: ${new Date().toISOString().slice(0, 10)}
353
351
  #
354
- # This file contains ONE section, because one section is what Codex runs from a
355
- # config fragment. Merge it into ~/.codex/config.toml.
352
+ # UNSUPPORTED: native MCP activation has no admitted no-fetch runtime route.
353
+ # This is a comment-only notice. Do not merge an executable MCP recipe or
354
+ # refresh a marketplace from this generated file (ADR-045).
356
355
  #
357
- # For skills, install the plugin instead of copying anything by hand:
358
- # ${plan.commands.join("\n# ")}
356
+ # Existing operator-managed sessions remain separate from verified activation.
359
357
  #
360
358
  # What does NOT come with either, on codex-cli as it stands today:
361
359
  ${notes}
@@ -363,12 +361,6 @@ ${notes}
363
361
  # Details, measured rather than inferred:
364
362
  # docs/analysis/2026-09-05-codex-phase0-findings.md
365
363
 
366
- [mcp_servers.great_cto]
367
- command = "npx"
368
- args = ["great-cto@latest", "mcp"]
369
- # Generous: npx downloads on first run.
370
- startup_timeout_sec = 60
371
-
372
364
  # Archetype: ${meta.archetype}
373
365
  # Compliance: ${meta.compliance.join(", ") || "none"}
374
366
  # Re-run great-cto adapt to refresh.
@@ -459,7 +451,7 @@ conventions. Generated by \`great-cto adapt\`.
459
451
  break;
460
452
  }
461
453
  case "codex": {
462
- // One file, and only the MCP section in it. The previous version also
454
+ // One comment-only notice. The previous version also
463
455
  // wrote `.codex/hooks.json` and then said "restart Codex to activate
464
456
  // hooks and MCP server" — half of which was true. A user who followed
465
457
  // that line came away believing the gate chain and secret-scan were
@@ -471,11 +463,8 @@ conventions. Generated by \`great-cto adapt\`.
471
463
  // than delete it: it is in the user's project, and it may be theirs now.
472
464
  const stale = join(cwd, ".codex", "hooks.json");
473
465
  if (!dryRun) {
474
- console.log(" ⚠ Codex: merge .codex/great_cto.toml into ~/.codex/config.toml, then restart Codex.");
475
- console.log(" That gives you the MCP server. For the skills, install the plugin:");
476
- for (const c of codexInstallPlan({ repoDir: "<path-to-great_cto>", codexOnPath: true }).commands) {
477
- console.log(` ${c}`);
478
- }
466
+ console.log(" ⚠ Codex: UNSUPPORTED native activation pending an admitted no-fetch runtime route (ADR-045).");
467
+ console.log(" .codex/great_cto.toml is comment-only; no active MCP command or registry fallback was generated.");
479
468
  console.log(" Hooks, slash commands and the role agents do NOT carry over to Codex today —");
480
469
  console.log(" see docs/analysis/2026-09-05-codex-phase0-findings.md.");
481
470
  if (existsSync(stale)) {
package/dist/codex.js CHANGED
@@ -72,7 +72,6 @@ export function codexInstallPlan({ repoDir, codexOnPath }) {
72
72
  import { existsSync as fsExists, readdirSync, readFileSync as fsRead } from "node:fs";
73
73
  import { homedir as home } from "node:os";
74
74
  import { join as pjoin } from "node:path";
75
- import { execFileSync } from "node:child_process";
76
75
  /** A usable `codex` binary: PATH first, then nvm installs, then the desktop app. Null when none. */
77
76
  export function findCodexBinary(env = process.env, h = home()) {
78
77
  for (const dir of String(env.PATH || "").split(":")) {
@@ -134,18 +133,8 @@ export function upgradeCodexPlugin(opts = {}) {
134
133
  return { status: "skipped", fromVersion: null, toVersion: null, reason: "great_cto is not installed for Codex" };
135
134
  }
136
135
  const from = installedCodexVersion(codexHome);
137
- const bin = opts.bin === undefined ? findCodexBinary() : opts.bin;
138
- if (!bin) {
139
- return { status: "skipped", fromVersion: from, toVersion: from, reason: "codex not found on PATH or under ~/.nvm — run: codex plugin marketplace upgrade great-cto" };
140
- }
141
- try {
142
- execFileSync(bin, ["plugin", "marketplace", "upgrade", "great-cto"], {
143
- stdio: ["ignore", "pipe", "pipe"], timeout: 180_000, env: { ...process.env, CODEX_HOME: codexHome },
144
- });
145
- }
146
- catch (e) {
147
- return { status: "failed", fromVersion: from, toVersion: from, reason: String(e.message).split("\n")[0] };
148
- }
149
- const to = installedCodexVersion(codexHome);
150
- return { status: to && to !== from ? "upgraded" : "already_latest", fromVersion: from, toVersion: to };
136
+ return {
137
+ status: "failed", fromVersion: from, toVersion: from,
138
+ reason: "verified native Codex activation is unsupported until no-fetch runtime and preactivation/recovery admission are proven; marketplace refresh was not invoked",
139
+ };
151
140
  }
package/dist/companion.js CHANGED
@@ -1,25 +1,4 @@
1
- /**
2
- * Companion plugin installer — superpowers + beads.
3
- *
4
- * great_cto requires these two plugins to be present. This module installs
5
- * them automatically during `great-cto init/install` so users get a working
6
- * setup in one command without reading the "Requires:" line in the README.
7
- *
8
- * Install strategy:
9
- * - Clone (depth=1) the latest semver tag from GitHub into
10
- * ~/.claude/plugins/cache/local/<name>/<version>/
11
- * - Enable <name>@local in ~/.claude/settings.json
12
- * - Idempotent — skips if any version is already present in the cache dir
13
- * - Best-effort — never fails the parent install; logs a human-friendly
14
- * hint if git is unavailable or clone fails
15
- */
16
- import { spawnSync, execFileSync } from "node:child_process";
17
- import { existsSync, mkdirSync, readdirSync } from "node:fs";
18
- import { homedir } from "node:os";
19
- import { join } from "node:path";
20
- import { dim, success, log, warn } from "./ui.js";
21
- import { enablePlugin } from "./settings.js";
22
- import { semverDescending } from "./semver.js";
1
+ /** Companion metadata is discoverable; installation requires separate artifact admission. */
23
2
  export const COMPANION_PLUGINS = [
24
3
  {
25
4
  name: "superpowers",
@@ -32,107 +11,12 @@ export const COMPANION_PLUGINS = [
32
11
  repoUrl: "https://github.com/steveyegge/beads.git",
33
12
  },
34
13
  ];
35
- function getPluginCacheDir(name) {
36
- return join(homedir(), ".claude", "plugins", "cache", "local", name);
37
- }
38
- /** Returns true if any version folder already exists in the cache dir. */
39
- function isAlreadyInstalled(name) {
40
- const base = getPluginCacheDir(name);
41
- if (!existsSync(base))
42
- return null;
43
- try {
44
- const versions = readdirSync(base).filter((v) => /\S/.test(v));
45
- if (versions.length === 0)
46
- return null;
47
- // Return the highest version, not filesystem order — matches
48
- // upgrade.ts/installer.ts which both sort before picking.
49
- return versions.sort(semverDescending)[0];
50
- }
51
- catch {
52
- return null;
53
- }
54
- }
55
- /** Detect the latest semver tag from a remote repo without cloning. */
56
- export function detectLatestTag(repoUrl) {
57
- try {
58
- const out = execFileSync("git", ["ls-remote", "--tags", repoUrl], {
59
- encoding: "utf-8",
60
- timeout: 15_000,
61
- stdio: ["ignore", "pipe", "pipe"],
62
- });
63
- const tags = out
64
- .split("\n")
65
- .map((line) => line.match(/refs\/tags\/v?([0-9]+\.[0-9]+\.[0-9]+)(?!\^)/)?.[1])
66
- .filter((t) => !!t)
67
- .sort(semverDescending);
68
- return tags[0] ?? null;
69
- }
70
- catch {
71
- return null;
72
- }
73
- }
74
- /**
75
- * Install a single companion plugin.
76
- * Silent no-op if already present. Best-effort on failure.
77
- */
14
+ /** Third-party snapshots have no publisher admission or managed recovery contract. */
15
+ export const COMPANION_ADMISSION_REQUIRED = "UNSUPPORTED: companion artifact admission and managed activation are not established; no files changed";
16
+ /** Refuse before network, cache inspection, settings enablement or overlay writes. */
78
17
  export function installCompanionPlugin(plugin) {
79
- const { name, pluginKey, repoUrl } = plugin;
80
- // ── already installed? ─────────────────────────────────────────────────
81
- const existing = isAlreadyInstalled(name);
82
- if (existing) {
83
- // Make sure it's enabled in settings even if it was manually placed
84
- enablePlugin(pluginKey);
85
- return { name, status: "already_present", version: existing };
86
- }
87
- // ── git available? ─────────────────────────────────────────────────────
88
- try {
89
- execFileSync("git", ["--version"], { stdio: "ignore", timeout: 5_000 });
90
- }
91
- catch {
92
- const msg = `git not found — install git, then run: npx great-cto install`;
93
- warn(`${name}: ${msg}`);
94
- return { name, status: "skipped", version: "—", reason: msg };
95
- }
96
- // ── resolve version ────────────────────────────────────────────────────
97
- const tag = detectLatestTag(repoUrl);
98
- const version = tag ?? "main";
99
- const ref = tag ? `v${tag}` : "main";
100
- const destDir = join(getPluginCacheDir(name), version);
101
- mkdirSync(getPluginCacheDir(name), { recursive: true });
102
- log(dim(` installing ${name} ${version}…`));
103
- // ── clone ──────────────────────────────────────────────────────────────
104
- const cloneArgs = tag
105
- ? ["clone", "--depth=1", "--branch", ref, repoUrl, destDir]
106
- : ["clone", "--depth=1", repoUrl, destDir];
107
- const result = spawnSync("git", cloneArgs, {
108
- stdio: ["ignore", "pipe", "pipe"],
109
- timeout: 60_000,
110
- });
111
- if (result.status !== 0) {
112
- const stderr = (result.stderr?.toString() ?? "").slice(0, 200);
113
- const msg = `clone failed: ${stderr}`;
114
- warn(`${name}: ${msg}`);
115
- warn(` install manually: claude plugin install github.com/${repoUrl.replace("https://github.com/", "").replace(".git", "")}`);
116
- return { name, status: "skipped", version: "—", reason: msg };
117
- }
118
- // ── enable in settings ─────────────────────────────────────────────────
119
- enablePlugin(pluginKey);
120
- success(`${name} ${version} installed`);
121
- return { name, status: "installed", version };
18
+ return { name: plugin.name, status: "skipped", version: "—", reason: COMPANION_ADMISSION_REQUIRED };
122
19
  }
123
- /**
124
- * Install all companion plugins declared in COMPANION_PLUGINS.
125
- * Returns a summary array. Never throws — best-effort for all.
126
- */
127
20
  export function installAllCompanions() {
128
- return COMPANION_PLUGINS.map((plugin) => {
129
- try {
130
- return installCompanionPlugin(plugin);
131
- }
132
- catch (e) {
133
- const msg = e instanceof Error ? e.message : String(e);
134
- warn(`${plugin.name}: unexpected error — ${msg}`);
135
- return { name: plugin.name, status: "skipped", version: "—", reason: msg };
136
- }
137
- });
21
+ return COMPANION_PLUGINS.map(installCompanionPlugin);
138
22
  }