great-cto 3.70.0 → 3.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/packages/board/lib/routes.mjs +14 -1
  4. package/board/packages/board/lib/work.mjs +34 -3
  5. package/board/packages/board/public/assets/admin-shell.css +63 -0
  6. package/board/packages/board/public/assets/admin-shell.js +39 -5
  7. package/board/packages/board/public/assets/work-view.js +320 -37
  8. package/board/packages/board/public/index.html +43 -1
  9. package/board/scripts/codex-pipeline.mjs +7 -1
  10. package/board/scripts/lib/claude-exec.mjs +49 -6
  11. package/board/scripts/lib/codex-exec.mjs +42 -18
  12. package/board/scripts/lib/codex-host-state.mjs +18 -2
  13. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  14. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  15. package/board/scripts/lib/host-graph-events.mjs +157 -0
  16. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  17. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  18. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  19. package/board/scripts/work-task.mjs +17 -4
  20. package/dist/adapt.js +10 -21
  21. package/dist/codex.js +4 -15
  22. package/dist/companion.js +6 -122
  23. package/dist/installer.js +4 -72
  24. package/dist/main.js +51 -118
  25. package/dist/release-activation.mjs +561 -0
  26. package/dist/release-archive.mjs +165 -0
  27. package/dist/release-trust.mjs +514 -0
  28. package/dist/release-update.mjs +303 -0
  29. package/dist/self-upgrade.js +10 -39
  30. package/dist/update-check.js +1 -0
  31. package/dist/upgrade.js +5 -120
  32. package/package.json +1 -1
  33. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  34. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  35. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  36. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  37. package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
@@ -0,0 +1,157 @@
1
+ /** Host observations are metadata, never controller approval or execution authority. */
2
+ import { createHash } from 'node:crypto';
3
+
4
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ const safeId = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value) ? value : null;
6
+ const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
7
+ const version = value => typeof value === 'string' && /^\d{1,5}(?:\.\d{1,5}){0,3}(?:[-+][a-zA-Z0-9.-]{1,32})?$/.test(value) ? value : null;
8
+ const hosts = new Set(['codex', 'claude-code']);
9
+ const modes = new Set(['controlled', 'native']);
10
+ const timestamp = value => typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
11
+
12
+ /** Supported event families do not attest that a particular run was fully observed. */
13
+ export function hostGraphCapabilities({ host, mode, sourceVersion } = {}) {
14
+ const known = false; // Version compatibility is established by the collector's capability audit, never semver syntax alone.
15
+ return { lifecycle: known ? 'supported' : 'unknown', tools: known ? 'supported' : 'unknown',
16
+ parentChild: known ? 'supported' : 'unknown', verification: 'unsupported', gates: 'unsupported', artifacts: 'unsupported' };
17
+ }
18
+
19
+ function toolClass(name) {
20
+ if (typeof name !== 'string') return 'other';
21
+ if (/^(?:mcp__|mcp:)/.test(name)) return 'mcp';
22
+ const classes = { Bash: 'shell', shell: 'shell', command_execution: 'shell', Read: 'read', read_file: 'read',
23
+ Edit: 'write', Write: 'write', MultiEdit: 'write', apply_patch: 'write', file_change: 'write',
24
+ Glob: 'search', Grep: 'search', WebSearch: 'search', web_search: 'search',
25
+ Agent: 'agent', Task: 'agent', collab_tool_call: 'agent', mcp_tool_call: 'mcp' };
26
+ return Object.hasOwn(classes, name) ? classes[name] : 'other';
27
+ }
28
+ const opaque = value => typeof value === 'string' && value.length > 0 && value.length <= 512 ? hash(value) : null;
29
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
30
+ export function codexNativeTurnIdentity(raw) {
31
+ if (!['UserPromptSubmit', 'Stop'].includes(raw?.hook_event_name)) return null;
32
+ return safeId(raw.event_id) || safeId(raw.turn_id);
33
+ }
34
+ const codexTrackedItems = new Set(['command_execution', 'file_change', 'mcp_tool_call', 'web_search', 'collab_tool_call']);
35
+ function ignoredClaudeBlock(block) {
36
+ if (!record(block)) return false;
37
+ return (block.type === 'text' && typeof block.text === 'string')
38
+ || (block.type === 'thinking' && typeof block.thinking === 'string')
39
+ || (block.type === 'redacted_thinking' && typeof block.data === 'string');
40
+ }
41
+ function ignoredPayload(host, mode, raw, sourceVersion) {
42
+ if (host === 'codex' && !(mode === 'native' && raw.hook_event_name)) {
43
+ if (!['item.started', 'item.updated', 'item.completed'].includes(raw.type) || !record(raw.item) || !opaque(raw.item.id)) return false;
44
+ const item = raw.item;
45
+ if (raw.type === 'item.updated' && codexTrackedItems.has(item.type)) return true;
46
+ if (['agent_message', 'reasoning'].includes(item.type)) return typeof item.text === 'string';
47
+ return item.type === 'todo_list' && Array.isArray(item.items)
48
+ && item.items.every(value => record(value) && typeof value.text === 'string' && typeof value.completed === 'boolean');
49
+ }
50
+ if (host !== 'claude-code') return false;
51
+ // Audited 2.1.292 schema: this push invalidates rendered UI trees only.
52
+ // Keep future versions, instance variants and extra structural fields visible
53
+ // as gaps until independently classified; subtype alone is not evidence.
54
+ if (mode === 'controlled' && sourceVersion === '2.1.292' && raw.type === 'system' && raw.subtype === 'ui_invalidate'
55
+ && raw.event === 'ui.render' && uuid(raw.uuid) && uuid(raw.session_id)
56
+ && Object.keys(raw).length === 5
57
+ && Object.keys(raw).every(key => ['type', 'subtype', 'event', 'uuid', 'session_id'].includes(key))) return true;
58
+ if (['assistant', 'user'].includes(raw.type)) {
59
+ const content = raw.message?.content;
60
+ return Array.isArray(content) && content.length > 0 && content.every(ignoredClaudeBlock);
61
+ }
62
+ // These advisory frames carry no graph lifecycle or causal relationship.
63
+ if (raw.type === 'system' && raw.subtype === 'status') return raw.status === null || raw.status === 'compacting';
64
+ if (raw.type === 'system' && raw.subtype === 'compact_boundary') return record(raw.compact_metadata)
65
+ && ['manual', 'auto'].includes(raw.compact_metadata.trigger) && Number.isSafeInteger(raw.compact_metadata.pre_tokens) && raw.compact_metadata.pre_tokens >= 0;
66
+ return raw.type === 'rate_limit_event' && record(raw.rate_limit_info)
67
+ && ['allowed', 'allowed_warning', 'rejected'].includes(raw.rate_limit_info.status);
68
+ }
69
+ function codexEvent(raw) {
70
+ if (raw.type === 'thread.started') return { kind: 'session', phase: 'started', source: raw.thread_id };
71
+ if (raw.type === 'turn.started') return { kind: 'turn', phase: 'started' };
72
+ if (raw.type === 'turn.completed') return { kind: 'turn', phase: 'completed', outcome: 'succeeded' };
73
+ if (raw.type === 'turn.failed') return { kind: 'turn', phase: 'failed', outcome: 'failed' };
74
+ if (raw.type === 'error') return { kind: 'error', phase: 'failed', outcome: 'failed' };
75
+ if (!['item.started', 'item.completed'].includes(raw.type) || !raw.item || typeof raw.item !== 'object') return null;
76
+ const item = raw.item;
77
+ if (!codexTrackedItems.has(item.type)) return null;
78
+ const status = String(item.status || '').toLowerCase();
79
+ const started = raw.type === 'item.started';
80
+ const outcome = started ? 'unknown' : ['failed', 'errored'].includes(status) || (Number.isInteger(item.exit_code) && item.exit_code !== 0) ? 'failed'
81
+ : ['declined', 'cancelled', 'canceled', 'interrupted'].includes(status) ? 'cancelled' : status === 'completed' ? 'succeeded' : 'unknown';
82
+ return { kind: item.type === 'collab_tool_call' ? 'child' : 'tool', phase: started ? 'started' : outcome === 'failed' ? 'failed' : 'completed',
83
+ outcome, source: item.id, toolClass: toolClass(item.type) };
84
+ }
85
+ function claudeEvent(raw) {
86
+ const hook = raw.hook_event_name;
87
+ const hooks = {
88
+ SessionStart: { kind: 'session', phase: 'started' }, SessionEnd: { kind: 'session', phase: 'stopped' },
89
+ UserPromptSubmit: { kind: 'turn', phase: 'started' }, Stop: { kind: 'turn', phase: 'stopped' },
90
+ PreToolUse: { kind: 'tool', phase: 'started' }, PostToolUse: { kind: 'tool', phase: 'completed', outcome: 'succeeded' },
91
+ PostToolUseFailure: { kind: 'tool', phase: 'failed', outcome: 'failed' },
92
+ SubagentStart: { kind: 'child', phase: 'started' }, SubagentStop: { kind: 'child', phase: 'stopped' },
93
+ };
94
+ if (Object.hasOwn(hooks, hook || '')) return { ...hooks[hook], source: raw.tool_use_id || raw.agent_id || raw.event_id,
95
+ toolClass: hooks[hook].kind === 'tool' ? toolClass(raw.tool_name) : hooks[hook].kind === 'child' ? 'agent' : null };
96
+ if (raw.type === 'system' && raw.subtype === 'init') return { kind: 'session', phase: 'started', source: raw.session_id };
97
+ if (raw.type === 'result') return { kind: 'turn', phase: raw.is_error === true ? 'failed' : 'completed',
98
+ outcome: raw.is_error === true ? 'failed' : raw.is_error === false || raw.subtype === 'success' ? 'succeeded' : 'unknown', source: raw.uuid };
99
+ if (!['assistant', 'user'].includes(raw.type)) return null;
100
+ const content = raw.message?.content;
101
+ if (!Array.isArray(content) || content.length !== 1 || !record(content[0])) return null;
102
+ const b = content[0];
103
+ if (b.type === 'tool_use' ? !opaque(b.id) || typeof b.name !== 'string' || !b.name.length
104
+ : b.type === 'tool_result' ? !opaque(b.tool_use_id) || (b.is_error !== undefined && typeof b.is_error !== 'boolean') : true) return null;
105
+ return b.type === 'tool_use' ? { kind: 'tool', phase: 'started', source: b.id, toolClass: toolClass(b.name) }
106
+ : { kind: 'tool', phase: b.is_error === true ? 'failed' : 'completed', outcome: b.is_error === true ? 'failed' : b.is_error === false ? 'succeeded' : 'unknown', source: b.tool_use_id, toolClass: 'other' };
107
+ }
108
+
109
+ /** Bindings come from the operator-owned collector, not fields inside the host payload. */
110
+ export function normalizeHostEvent({ host, mode, sourceVersion, event: raw, binding } = {}) {
111
+ const capabilities = hostGraphCapabilities({ host, mode, sourceVersion });
112
+ const rejected = reason => ({ event: null, diagnostics: [reason], capabilities });
113
+ if (!hosts.has(host) || !modes.has(mode)) return rejected('unsupported-host-or-mode');
114
+ if (!binding || !safeId(binding.projectId) || !safeId(binding.runId) || !safeId(binding.sessionId)
115
+ || (binding.attemptId != null && !safeId(binding.attemptId))) return rejected('invalid-trusted-binding');
116
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return rejected('invalid-event');
117
+ if (ignoredPayload(host, mode, raw, sourceVersion)) return { event: null, diagnostics: [], capabilities };
118
+ const hookShape = mode === 'native' && typeof raw.hook_event_name === 'string';
119
+ const parsed = host === 'codex' && !hookShape ? codexEvent(raw) : claudeEvent(raw);
120
+ if (!parsed) return rejected('unsupported-event-shape');
121
+ if (host === 'codex' && hookShape) {
122
+ // Codex PostToolUse also fires for failed calls; output content is not a status.
123
+ if (raw.hook_event_name === 'PostToolUse') parsed.outcome = 'unknown';
124
+ if (['UserPromptSubmit', 'Stop'].includes(raw.hook_event_name)) parsed.source = codexNativeTurnIdentity(raw);
125
+ }
126
+ const parents = binding.parentIds ?? [];
127
+ if (!Array.isArray(parents) || parents.length > 16 || parents.some(p => !safeId(p))) return rejected('invalid-trusted-parents');
128
+ const diagnostics = [];
129
+ if (parsed.kind === 'child' && !parents.length) diagnostics.push('missing-explicit-parent');
130
+ const sequence = Number.isSafeInteger(binding.sequence) && binding.sequence >= 0 ? binding.sequence : null;
131
+ const rawSourceId = binding.sourceEventId ?? parsed.source ?? binding.turnId ?? null;
132
+ const sourceEventId = opaque(rawSourceId);
133
+ if (rawSourceId != null && !sourceEventId) diagnostics.push('missing-source-event-identity');
134
+ if (!sourceEventId && sequence === null && (['tool', 'child'].includes(parsed.kind) || (parsed.kind === 'turn' && binding.attemptId == null))) diagnostics.push('missing-source-event-identity');
135
+ if (!version(sourceVersion)) diagnostics.push('source-version-unverified');
136
+ const event = { schemaVersion: 1, projectId: binding.projectId, runId: binding.runId, sessionId: binding.sessionId,
137
+ attemptId: binding.attemptId ?? null, host, mode, sourceVersion: version(sourceVersion), sourceEventId, sequence,
138
+ kind: parsed.kind, phase: parsed.phase, outcome: parsed.outcome || 'unknown', parentIds: [...new Set(parents)],
139
+ occurredAt: timestamp(raw.timestamp ?? raw.at), toolClass: parsed.toolClass ?? null,
140
+ callId: ['tool', 'child'].includes(parsed.kind) ? opaque(parsed.source ?? binding.sourceEventId ?? null) : null };
141
+ // Collector wall clock is excluded: replaying an identical source event is idempotent.
142
+ return { event: { ...event, id: hash(event), observedAt: new Date().toISOString() }, diagnostics: [...new Set(diagnostics)], capabilities };
143
+ }
144
+
145
+ /** A streamed Claude message may carry several independent tool blocks. */
146
+ export function normalizeHostEvents(input = {}) {
147
+ const content = input.event?.message?.content;
148
+ if (input.host !== 'claude-code' || !['assistant', 'user'].includes(input.event?.type) || !Array.isArray(content)) {
149
+ const result = normalizeHostEvent(input);
150
+ return { events: result.event ? [result.event] : [], diagnostics: result.diagnostics, capabilities: result.capabilities };
151
+ }
152
+ if (!content.length) return { events: [], diagnostics: ['unsupported-event-shape'], capabilities: hostGraphCapabilities(input) };
153
+ const results = content.map(block => normalizeHostEvent({ ...input,
154
+ event: { ...input.event, message: { content: [block] } } }));
155
+ return { events: results.flatMap(r => r.event ? [r.event] : []),
156
+ diagnostics: [...new Set(results.flatMap(r => r.diagnostics))], capabilities: hostGraphCapabilities(input) };
157
+ }
@@ -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; }
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)) {