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
@@ -69,11 +69,34 @@
69
69
  function cockpitEntries(data, category, search) {
70
70
  return data.entries.filter(e => inCategory(e, category) && (!search || `${e.title} ${e.key}`.toLowerCase().includes(search.toLowerCase())));
71
71
  }
72
+ function renderDeclaredWorkflow(graph) {
73
+ if (!graph || graph.source === 'host-observation') return '';
74
+ const snapshots = Array.isArray(graph.snapshots) ? graph.snapshots.slice(0, 64) : [];
75
+ return '<details class="work-declared-workflow"><summary>Declared workflow' + (snapshots.length ? ` · ${snapshots.length} snapshots` : '') + '</summary>'
76
+ + '<p>Declared possibilities only. These rules do not prove that a stage ran, a dependency was satisfied or a gate was approved.</p>'
77
+ + (snapshots.length ? snapshots.map(snapshot => `<details><summary>Workflow snapshot ${esc(snapshot.digest || 'identity unavailable')}</summary>`
78
+ + '<table><caption>Declared transition rules</caption><thead><tr><th>Role</th><th>On</th><th>Next</th><th>Join</th><th>Gates</th></tr></thead><tbody>'
79
+ + (Array.isArray(snapshot.rules) ? snapshot.rules.slice(0, 256) : []).map(rule => '<tr>' + [rule.role, ...['on', 'next', 'join', 'gates'].map(key => Array.isArray(rule[key]) ? rule[key].slice(0, 16).join(', ') : '')].map(value => `<td>${esc(value || 'None declared')}</td>`).join('') + '</tr>').join('') + '</tbody></table></details>').join('')
80
+ : '<p>No declared workflow snapshot is recorded.</p>') + '</details>';
81
+ }
82
+ function renderHostAttempt(graph, attempts, options) {
83
+ const attemptId = graph.binding?.attemptId || graph.nodes?.find(node => node.attemptId)?.attemptId;
84
+ const attempt = attempts.find(node => node.attemptId === attemptId);
85
+ const hostKey = graph.binding?.host || graph.nodes?.[0]?.host;
86
+ const host = hostKey === 'codex' ? 'Codex' : hostKey === 'claude-code' ? 'Claude Code' : 'Host unknown';
87
+ const label = attempt ? `${attempt.role || 'Role unknown'} · attempt ${attempt.number || attemptId}` : attemptId ? `attempt ${attemptId}` : 'session observations';
88
+ const key = JSON.stringify([graph.binding?.host || null, graph.binding?.runId || null, graph.binding?.sessionId || null, attemptId || null]);
89
+ return `<details class="work-host-attempt" data-host-attempt="${esc(key)}" data-observation-key="${esc(key)}"><summary>Host observations · ${esc(host)} · ${esc(label)}</summary>${renderExecutionGraph(graph, options)}</details>`;
90
+ }
72
91
  function renderExecution(entry, options = {}) {
73
92
  const roles = Array.isArray(entry.evidence) ? entry.evidence : [];
74
93
  const execution = entry.execution, nodes = Array.isArray(execution?.nodes) ? execution.nodes.slice(0, 200) : [];
75
94
  return (options.canvas ? '<section class="work-execution work-execution-flat">' : '<section class="work-execution"><h3>Recorded observations</h3>')
95
+ + (execution?.observationHealth?.state === 'degraded' ? '<p class="work-warning">Host observation delivery is incomplete.</p>' : '')
96
+ + (execution?.omittedObservationAttempts ? `<p class="work-warning">${esc(execution.omittedObservationAttempts)} earlier attempt observation journals are outside this bounded view.</p>` : '')
97
+ + renderDeclaredWorkflow(execution?.graph)
76
98
  + renderExecutionGraph(execution?.graph, options)
99
+ + (Array.isArray(execution?.observations) ? execution.observations.slice(0, 16).map(graph => renderHostAttempt(graph, nodes, options)).join('') : '')
77
100
  + (execution?.graph?.state === 'recorded' ? '' : '<p>Dependencies not recorded. This is an observation list, not an inferred execution graph. Event order does not establish dependencies.</p>')
78
101
  + `<p>Recorded stage: ${esc(entry.stage || 'not recorded')}</p>`
79
102
  + (execution?.reason ? `<p>${esc(execution.reason)}</p>` : '')
@@ -90,25 +113,81 @@
90
113
  + (options.canvas ? '</details>' : '')
91
114
  + '</section>';
92
115
  }
116
+ function renderHostCoverage(graph) {
117
+ const dimensions = [['lifecycle', 'Lifecycle'], ['parentChild', 'Parent / child'], ['tools', 'Tools'], ['verification', 'Verification'], ['gates', 'Gates'], ['artifacts', 'Artifacts']];
118
+ const total = Number.isSafeInteger(graph.total) && graph.total >= 0 ? graph.total : 'unavailable';
119
+ return `<div class="execution-host-coverage"><p class="work-meta">Source: host observation · Health: ${esc(graph.health || 'unknown')}. Observation metadata does not establish current liveness, controller transitions, gate approval or task acceptance.</p>`
120
+ + (graph.observedInterval ? `<p>Observed interval · collector receipt times: ${esc(graph.observedInterval.from || 'Unavailable')} to ${esc(graph.observedInterval.to || 'Unavailable')}</p>` : '')
121
+ + (Array.isArray(graph.diagnostics) && graph.diagnostics.length ? '<details class="work-observation-diagnostics"><summary>Observation diagnostics</summary><ul>' + graph.diagnostics.slice(0, 16).map(code => `<li><code>${esc(code)}</code></li>`).join('') + '</ul></details>' : '')
122
+ + `<p>Showing ${Array.isArray(graph.nodes) ? graph.nodes.length : 0} of ${esc(total)} recorded observations.${graph.cursor ? ' More observations are available outside this page.' : ''}</p>`
123
+ + (graph.cursor ? `<details><summary>Observation page cursor · read-only</summary><code>${esc(graph.cursor)}</code></details>` : '')
124
+ + (graph.cursor && graph.binding?.host && graph.binding?.sessionId ? `<button type="button" data-host-graph-next="${esc(JSON.stringify({ ...graph.binding, cursor: graph.cursor }))}">Next observation page</button>` : '')
125
+ + '<dl aria-label="Host observation coverage">' + dimensions.map(([key, label]) => {
126
+ const dimension = graph.coverage?.[key];
127
+ const support = dimension?.supported === true ? 'Supported' : dimension?.supported === false ? 'Unsupported' : 'Support unknown';
128
+ const state = dimension?.supported === false ? 'Unavailable' : dimension?.state === 'partial' ? 'Partial'
129
+ : dimension?.supported === true && dimension?.state === 'complete' ? 'Complete' : 'Unavailable';
130
+ return `<dt>${label} · ${support} · ${state}</dt><dd>${esc(dimension?.reason || 'Coverage has not been established.')}</dd>`;
131
+ }).join('') + '</dl></div>';
132
+ }
133
+ const controllerOwnedKinds = new Set(['transition', 'context', 'checks', 'gate', 'approval', 'rework', 'recovery', 'wave', 'manual', 'cancel', 'release', 'reuse']);
134
+ const graphLane = node => ['codex', 'claude-code'].includes(node.host) ? node.host
135
+ : !node.host && controllerOwnedKinds.has(node.kind) ? 'controller' : 'unknown';
136
+ const graphHostLabel = node => node.host || (graphLane(node) === 'controller' ? 'controller' : 'Host not recorded');
137
+ const graphRoleLabel = node => node.role || (graphLane(node) === 'controller' ? 'controller' : node.kind || 'Role not recorded');
93
138
  function renderExecutionGraph(graph, options = {}) {
139
+ const hostObserved = graph?.source === 'host-observation';
140
+ const coverage = hostObserved ? renderHostCoverage(graph) : '';
94
141
  if (graph?.state !== 'recorded' || !Array.isArray(graph.nodes) || !graph.nodes.length)
95
- return `<p class="work-meta">${esc(graph?.reason || 'Controller-recorded graph unavailable. Native and legacy observations remain partial.')}</p>`;
96
- if (graph.nodes.length > 512 || !Array.isArray(graph.edges) || graph.edges.length > 2048) return '<p>Graph exceeds the display limit.</p>';
142
+ return (hostObserved ? '<section class="execution-graph" aria-label="Host-observed execution graph"><h3>Host-observed graph</h3>' + coverage : '')
143
+ + `<p class="work-meta">${esc(graph?.reason || 'Controller-recorded graph unavailable. Native and legacy observations remain partial.')}</p>` + (hostObserved ? '</section>' : '');
144
+ if (graph.nodes.length > 512 || !Array.isArray(graph.edges) || graph.edges.length > 2048) return coverage + '<p>Graph exceeds the display limit.</p>';
97
145
  const horizontal = options.canvas && options.direction !== 'tb';
98
- const nodes = graph.nodes, ids = new Map(nodes.map((n, i) => [n.id, i]));
99
- if (ids.size !== nodes.length || graph.edges.some(e => !ids.has(e.from) || !ids.has(e.to) || ids.get(e.from) >= ids.get(e.to)))
100
- return '<p>Graph dependencies cannot be displayed safely.</p>';
146
+ let nodes = graph.nodes;
147
+ const ids = new Map(nodes.map((n, i) => [n.id, i]));
148
+ if (ids.size !== nodes.length || graph.edges.some(e => !ids.has(e.from) || !ids.has(e.to) || e.from === e.to || (!hostObserved && ids.get(e.from) >= ids.get(e.to))))
149
+ return coverage + '<p>Graph dependencies cannot be displayed safely.</p>';
150
+ if (hostObserved) {
151
+ // Explicit parents may arrive late. Reorder for layout only; never add an edge.
152
+ const indegree = new Map(nodes.map(n => [n.id, 0])), children = new Map(nodes.map(n => [n.id, []]));
153
+ for (const edge of graph.edges) { indegree.set(edge.to, indegree.get(edge.to) + 1); children.get(edge.from).push(edge.to); }
154
+ const pending = nodes.filter(n => indegree.get(n.id) === 0), ordered = [], byId = new Map(nodes.map(n => [n.id, n]));
155
+ for (let index = 0; index < pending.length; index += 1) {
156
+ const node = pending[index]; ordered.push(node);
157
+ for (const child of children.get(node.id)) { indegree.set(child, indegree.get(child) - 1); if (indegree.get(child) === 0) pending.push(byId.get(child)); }
158
+ }
159
+ if (ordered.length !== nodes.length) return coverage + '<p>Graph dependencies cannot be displayed safely.</p>';
160
+ nodes = ordered;
161
+ }
101
162
  const levels = new Map(nodes.map(n => [n.id, 0]));
102
163
  const parents = new Map(nodes.map(n => [n.id, []]));
103
164
  for (const e of graph.edges) parents.get(e.to).push(e);
104
- const rows = new Map(), positions = new Map();
165
+ const laneFor = graphLane;
166
+ const lanes = ['controller', 'codex', 'claude-code', 'unknown'].filter(host => nodes.some(node => laneFor(node) === host)), laneLayout = lanes.length > 1;
167
+ const rows = new Map(), positions = new Map(), nodeRows = new Map(), laneCounts = new Map();
105
168
  for (const n of nodes) {
106
169
  const level = Math.max(0, ...parents.get(n.id).map(e => levels.get(e.from) + 1));
107
170
  levels.set(n.id, level);
108
- const row = rows.get(level) || 0; rows.set(level, row + 1);
109
- positions.set(n.id, horizontal ? { x: 24 + level * 272, y: 24 + row * 112 } : { x: 24 + row * 236, y: 24 + level * 112 });
171
+ const key = laneLayout ? `${laneFor(n)}:${level}` : level, row = rows.get(key) || 0;
172
+ rows.set(key, row + 1); nodeRows.set(n.id, row);
173
+ laneCounts.set(laneFor(n), Math.max(laneCounts.get(laneFor(n)) || 0, row + 1));
174
+ }
175
+ const offsets = new Map(), extents = new Map(); let extent = 0;
176
+ if (laneLayout) for (const host of lanes) {
177
+ const size = (laneCounts.get(host) || 1) * (horizontal ? 112 : 236) + 48;
178
+ offsets.set(host, extent); extents.set(host, size); extent += size;
110
179
  }
111
- const width = horizontal ? (Math.max(...levels.values()) + 1) * 272 : Math.max(500, Math.max(...rows.values()) * 236 + 24), height = horizontal ? Math.max(240, Math.max(...rows.values()) * 112 + 24) : (Math.max(...levels.values()) + 1) * 112 + 24;
180
+ for (const n of nodes) {
181
+ const level = levels.get(n.id), row = nodeRows.get(n.id), offset = laneLayout ? offsets.get(laneFor(n)) : 0;
182
+ positions.set(n.id, horizontal ? { x: 24 + level * 272, y: (laneLayout ? offset + 48 : 24) + row * 112 }
183
+ : { x: offset + 24 + row * 236, y: (laneLayout ? 48 : 24) + level * 112 });
184
+ }
185
+ const width = horizontal ? (Math.max(...levels.values()) + 1) * 272 : laneLayout ? Math.max(500, extent) : Math.max(500, Math.max(...rows.values()) * 236 + 24);
186
+ const height = horizontal ? laneLayout ? extent : Math.max(240, Math.max(...rows.values()) * 112 + 24) : (Math.max(...levels.values()) + 1) * 112 + (laneLayout ? 48 : 24);
187
+ const laneBoxes = laneLayout ? lanes.map(host => {
188
+ const label = ({ controller: 'Controller', codex: 'Codex', 'claude-code': 'Claude Code', unknown: 'Host not recorded' })[host], offset = offsets.get(host);
189
+ return `<g class="execution-host-lane" role="group" aria-label="Host lane: ${label}"><rect x="${horizontal ? 0 : offset}" y="${horizontal ? offset : 0}" width="${horizontal ? width : extents.get(host)}" height="${horizontal ? extents.get(host) : height}" fill="var(--bg-page)" stroke="var(--border-strong)"/><text x="${horizontal ? 12 : offset + 12}" y="${horizontal ? offset + 24 : 24}" fill="currentColor">${label}</text></g>`;
190
+ }).join('') : '';
112
191
  const edges = graph.edges.map(e => {
113
192
  const a = positions.get(e.from), b = positions.get(e.to);
114
193
  if (horizontal) return `<path d="M${a.x + 208} ${a.y + 39} C${a.x + 240} ${a.y + 39},${b.x - 32} ${b.y + 39},${b.x} ${b.y + 39}" marker-end="url(#execution-arrow)"><title>${esc(e.kind)}: ${esc(e.from)} to ${esc(e.to)}</title></path>`;
@@ -117,22 +196,44 @@
117
196
  const short = value => String(value || '').length > 27 ? String(value).slice(0, 26) + '…' : String(value || '');
118
197
  const boxes = nodes.map(n => {
119
198
  const p = positions.get(n.id);
120
- return `<g class="execution-node" role="button" tabindex="0" data-graph-node="${esc(n.id)}" aria-label="${esc(n.kind)} ${esc(n.role || '')}: ${esc(n.status)}. Open recorded details." transform="translate(${p.x},${p.y})">${options.canvas ? `<title>${esc(n.kind)} · ${esc(n.role || 'controller')} · ${esc(n.status)}</title>` : ''}<rect width="208" height="78" rx="6"/><text x="12" y="23">${esc(short(n.role || n.kind))}</text><text x="12" y="45">${options.canvas ? esc(short(`${n.kind} · ${n.status}`)) : `${esc(n.kind)} · ${esc(n.status)}`}</text><text x="12" y="64">${esc(n.host || 'controller')}</text></g>`;
199
+ return `<g class="execution-node" role="button" tabindex="0" data-graph-node="${esc(n.id)}" data-graph-search-text="${esc([n.id, n.role || n.kind, graphHostLabel(n), n.attemptId || ''].join(' '))}" aria-label="${esc(n.kind)} ${esc(n.role || '')}: ${esc(n.status)}. Open recorded details." transform="translate(${p.x},${p.y})">${options.canvas ? `<title>${esc(n.kind)} · ${esc(graphRoleLabel(n))} · ${esc(n.status)}</title>` : ''}<rect width="208" height="78" rx="6"/><text x="12" y="23">${esc(short(n.role || n.kind))}</text><text x="12" y="45">${options.canvas ? esc(short(`${n.kind} · ${n.status}`)) : `${esc(n.kind)} · ${esc(n.status)}`}</text><text x="12" y="64">${esc(graphHostLabel(n))}</text></g>`;
121
200
  }).join('');
122
- const details = nodes.map(n => `<details class="execution-node-detail" data-graph-detail="${esc(n.id)}"><summary>${esc(n.id)} · ${esc(n.kind)} · ${esc(n.role || 'controller')} · ${esc(n.status)}</summary><dl>`
123
- + [['Attempt', n.attemptId], ['Host', n.host], ['Recorded at', n.at], ['Verdict', n.verdict], ['Wave', n.waveId], ['Gates', (n.gates || []).join(', ')],
201
+ const details = nodes.map(n => `<details class="execution-node-detail" data-graph-detail="${esc(n.id)}"><summary>${esc(n.id)} · ${esc(n.kind)} · ${esc(graphRoleLabel(n))} · ${esc(n.status)}</summary><dl>`
202
+ + [['Attempt', n.attemptId], ['Host', n.host], ...(hostObserved ? [['Source', n.source || 'host-observation'], ['Source version', n.sourceVersion || 'unverified'], ['Phase', n.phase], ['Outcome', n.outcome]] : []), ['Recorded at', n.at], ['Verdict', n.verdict], ['Wave', n.waveId], ['Gates', (n.gates || []).join(', ')],
124
203
  ['Workflow snapshot SHA-256', n.graphDigest], ['Input receipt SHA-256', n.inputDigest], ['Output receipt SHA-256', n.outputDigest],
125
204
  [n.kind === 'reuse' ? 'Historical report SHA-256 (worker not invoked)' : 'Stage context SHA-256', n.contextDigest], ['Result SHA-256', n.resultDigest]]
126
205
  .map(([key, value]) => `<dt>${esc(key)}</dt><dd><code>${esc(value || 'Not recorded')}</code></dd>`).join('')
127
206
  + `<dt>Recorded predecessors</dt><dd>${parents.get(n.id).map(e => `${esc(e.from)} (${esc(e.kind)})`).join(', ') || 'None recorded; not proof of complete external inputs'}</dd></dl></details>`).join('');
128
- return `<section class="execution-graph" aria-label="Controller-recorded execution graph"><h3>Controller-recorded graph</h3><p>${nodes.length} nodes · ${graph.edges.length} recorded edges · ${graph.snapshots?.length || 0} workflow snapshots. ${graph.complete ? 'Controller history recorded from run creation.' : 'Partial history: missing edges are not inferred.'}</p>`
129
- + `<p class="work-meta">Dispatch, input, verification, gate and join edges describe controller observations. Intent is not proof of a live worker; recorded approval is not deployment or task acceptance. Historical reuse does not invoke a worker. Stage context digests exclude separately admitted domain packets.</p>`
207
+ const latestAttempts = graph.nodes.filter(node => node.kind === 'attempt'), latestPool = latestAttempts.length ? latestAttempts : graph.nodes;
208
+ const latest = [...latestPool].sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0)).at(-1);
209
+ const navigation = `<div class="execution-graph-tools" role="group" aria-label="Graph node navigation"><label>Find graph node <input type="search" data-graph-find="query" maxlength="160" placeholder="Role, host or node ID"></label><button type="button" class="gate-btn" data-graph-search="find">Find node</button>${latest ? `<button type="button" class="gate-btn" data-graph-latest="${esc(latest.id)}">Latest recorded ${latestAttempts.length ? 'attempt' : 'observation'}</button>` : ''}<span data-graph-search-feedback role="status" aria-live="polite"></span></div>`;
210
+ return `<section class="execution-graph" aria-label="${hostObserved ? 'Host-observed' : 'Controller-recorded'} execution graph"><h3>${hostObserved ? 'Host-observed graph' : 'Controller-recorded graph'}</h3><p>${nodes.length} nodes · ${graph.edges.length} recorded edges${hostObserved ? '. Partial history: host delivery completeness is not attested.' : ` · ${graph.snapshots?.length || 0} workflow snapshots. ${graph.complete ? 'Controller history recorded from run creation.' : 'Partial history: missing edges are not inferred.'}`}</p>`
211
+ + (hostObserved ? coverage : `<p class="work-meta">Dispatch, input, verification, gate and join edges describe controller observations. Intent is not proof of a live worker; recorded approval is not deployment or task acceptance. Historical reuse does not invoke a worker. Stage context digests exclude separately admitted domain packets.</p>`)
130
212
  + (graph.reason ? `<p class="work-warning">${esc(graph.reason)}</p>` : '')
213
+ + (laneLayout ? '<p class="work-meta">Visual grouping only; host lanes do not establish dependency or concurrency.</p>' : '') + navigation
131
214
  + `<div class="execution-graph-tools execution-graph-toolbar" role="group" aria-label="Graph zoom"><button type="button" class="gate-btn" data-graph-scale="0.75">Zoom out</button><button type="button" class="gate-btn" data-graph-scale="1.333333">Zoom in</button><button type="button" class="gate-btn" data-graph-scale="reset">Reset zoom</button>${options.canvas ? `<button type="button" class="gate-btn" data-graph-scale="fit">Fit graph</button><button type="button" class="gate-btn" data-graph-direction="lr" aria-pressed="${horizontal}">Left to right</button><button type="button" class="gate-btn" data-graph-direction="tb" aria-pressed="${!horizontal}">Top to bottom</button>` : ''}</div>`
132
- + `<div class="execution-graph-canvas" tabindex="0" aria-label="Scrollable graph. Tab to a node and press Enter for details."><svg data-graph-layout="${horizontal ? 'lr' : 'tb'}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="group" aria-label="Recorded execution dependencies"><defs><marker id="execution-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8"/></marker></defs><g class="execution-edges" fill="none">${edges}</g>${boxes}</svg></div>`
215
+ + `<div class="execution-graph-canvas" tabindex="0" aria-label="Scrollable graph. Tab to a node and press Enter for details."><svg data-graph-layout="${horizontal ? 'lr' : 'tb'}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="group" aria-label="Recorded execution dependencies"><defs><marker id="execution-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8"/></marker></defs>${laneBoxes}<g class="execution-edges" fill="none">${edges}</g>${boxes}</svg></div>`
133
216
  + (options.canvas ? '<details class="execution-node-list"><summary>Recorded node details · keyboard list</summary>' : '<h3>Recorded node details</h3>') + `<p class="work-meta">Use the graph or expand a node below. Digests bind recorded inputs, not correctness.</p>${details}${options.canvas ? '</details>' : ''}</section>`;
134
217
  }
218
+ function focusGraphNode(node) {
219
+ if (!node) return false;
220
+ node.focus({ preventScroll: true }); node.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); return true;
221
+ }
222
+ function focusGraphMatch(graph, value) {
223
+ if (!graph) return;
224
+ const query = String(value || '').trim().slice(0, 160).toLowerCase();
225
+ const matches = [...graph.querySelectorAll('[data-graph-node]')].filter(node => String(node.dataset.graphSearchText || '').toLowerCase().includes(query));
226
+ const feedback = graph.querySelector('[data-graph-search-feedback]');
227
+ if (!query) { if (feedback) feedback.textContent = 'Enter a role, host or node ID.'; return; }
228
+ if (feedback) feedback.textContent = matches.length ? `Focused first of ${matches.length} matching nodes. All dependencies remain visible.` : 'No matching node on this page.';
229
+ if (matches.length) focusGraphNode(matches[0]);
230
+ }
135
231
  function graphInteraction(target) {
232
+ const search = target.closest('[data-graph-search]');
233
+ if (search?.dataset.graphSearch === 'find') { const graph = search.closest('.execution-graph'); focusGraphMatch(graph, graph?.querySelector('[data-graph-find]')?.value); return true; }
234
+ const latest = target.closest('[data-graph-latest]');
235
+ if (latest?.dataset.graphLatest) { const graph = latest.closest('.execution-graph'); focusGraphNode([...graph?.querySelectorAll('[data-graph-node]') || []].find(node => node.dataset.graphNode === latest.dataset.graphLatest)); return true; }
236
+
136
237
  const direction = target.closest('[data-graph-direction]');
137
238
  if (config?.canvas && ['lr', 'tb'].includes(direction?.dataset.graphDirection)) { graphDirection = direction.dataset.graphDirection; closeWorkDialog(false); paint(); document.querySelector?.('[data-graph-direction][aria-pressed="true"]')?.focus(); return true; }
138
239
  const node = target.closest('[data-graph-node]');
@@ -218,7 +319,7 @@
218
319
  : render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
219
320
  const runLabel = selected.title.match(/^Controlled run ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
220
321
  const title = runLabel ? `Controlled run <small class="work-meta work-run-identifier">${esc(runLabel[1].slice(0, 8))}</small>` : esc(selected.title);
221
- return `<section class="work-workspace work-detail-only" data-selected-work="${esc(selected.key)}"><header class="work-task-header"><div class="work-task-navigation">${back}<span class="work-meta">${esc(selected.kind)} · ${esc(selected.host || 'Host not linked')}</span><button type="button" data-work-switch>Switch task</button><button type="button" data-work-previous${rows.findIndex(e => e.key === selected.key) > 0 ? '' : ' disabled'} aria-label="Previous task in current filter">Previous</button><button type="button" data-work-next${rows.findIndex(e => e.key === selected.key) >= 0 && rows.findIndex(e => e.key === selected.key) < rows.length - 1 ? '' : ' disabled'} aria-label="Next task in current filter">Next</button></div><h2>${title}</h2><p>${esc(phaseLabel(selected))}</p></header>${tabs}${feedback}${tab === 'overview' ? '' : observation}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section>`;
322
+ return `<section class="work-workspace work-detail-only" data-selected-work="${esc(selected.key)}" data-observation-scope="${esc(JSON.stringify([options.observationProject || data.projectId || null, selected.key]))}"><header class="work-task-header"><div class="work-task-navigation">${back}<span class="work-meta">${esc(selected.kind)} · ${esc(selected.host || 'Host not linked')}</span><button type="button" data-work-switch>Switch task</button><button type="button" data-work-previous${rows.findIndex(e => e.key === selected.key) > 0 ? '' : ' disabled'} aria-label="Previous task in current filter">Previous</button><button type="button" data-work-next${rows.findIndex(e => e.key === selected.key) >= 0 && rows.findIndex(e => e.key === selected.key) < rows.length - 1 ? '' : ' disabled'} aria-label="Next task in current filter">Next</button></div><h2>${title}</h2><p>${esc(phaseLabel(selected))}</p></header>${tabs}${feedback}${tab === 'overview' ? '' : observation}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section>`;
222
323
  }
223
324
  function renderEvidence(sections) {
224
325
  if (!Array.isArray(sections)) return '';
@@ -344,6 +445,25 @@
344
445
  const dialog = makeWorkDialog('work-node-inspector', 'Recorded node details', opener, '<p class="work-meta">Recorded inputs, not proof of correctness, current activity or acceptance.</p>' + content, false);
345
446
  dialog.querySelector('summary')?.focus();
346
447
  }
448
+ async function nextObservationPage(button) {
449
+ const project = config.project(), entryKey = selectedKey, oldSnapshot = snapshot;
450
+ try {
451
+ const binding = JSON.parse(button.dataset.hostGraphNext), query = new URLSearchParams({ host: binding.host, session: binding.sessionId, run: binding.runId, cursor: binding.cursor });
452
+ if (project) query.set('project', project);
453
+ button.disabled = true;
454
+ const page = await config.read('/api/host-graph?' + query.toString());
455
+ if (project !== config.project() || entryKey !== selectedKey || snapshot !== oldSnapshot) return;
456
+ if (!page || page.error || page.source !== 'host-observation') throw Error('unavailable');
457
+ const entry = snapshot.entries.find(e => e.key === entryKey), graphs = [entry?.execution?.graph, ...(entry?.execution?.observations || [])];
458
+ const graph = graphs.find(g => g?.binding?.sessionId === binding.sessionId && g?.binding?.host === binding.host && g?.binding?.runId === binding.runId);
459
+ if (!graph) throw Error('unavailable');
460
+ Object.assign(graph, page); paint();
461
+ status(`Observation page loaded: ${page.nodes?.length || 0} recorded nodes.`);
462
+ const first = page.nodes?.[0]?.id;
463
+ if (first) [...document.querySelectorAll('[data-graph-node]')].find(node => node.dataset.graphNode === first)?.focus();
464
+ } catch { status('Observation page is unavailable. Refresh the selected project and retry.'); }
465
+ finally { if (button.isConnected) button.disabled = false; }
466
+ }
347
467
  async function refresh() {
348
468
  const seq = ++generation, project = config.project();
349
469
  try {
@@ -362,6 +482,9 @@
362
482
  function paint() {
363
483
  if (!snapshot || (config?.canvas && snapshotProject !== config.project())) return;
364
484
  previews.clear(); // Repaint removes the reviewed diff and its confirmation controls.
485
+ const oldWorkspace = document.querySelector?.('[data-observation-scope]');
486
+ const sameObservationScope = oldWorkspace?.dataset.observationScope === JSON.stringify([snapshotProject, selectedKey]);
487
+ const expandedHosts = sameObservationScope ? [...oldWorkspace.querySelectorAll('[data-observation-key][open]')].slice(0, 16).map(detail => detail.dataset.observationKey).filter(key => typeof key === 'string' && key.length <= 1024) : [];
365
488
  const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => `${d.closest('[data-entry-key]').dataset.entryKey}:${d.dataset.workDetail}`);
366
489
  const focused = document.activeElement;
367
490
  const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
@@ -370,12 +493,13 @@
370
493
  const oldCanvas = config.canvas ? document.querySelector?.('.execution-graph-canvas') : null;
371
494
  const oldSvg = oldCanvas?.querySelector('svg'), oldZoom = oldSvg ? Number(oldSvg.dataset.zoom || 1) : 1;
372
495
  const graphScroll = oldCanvas?.closest('[data-selected-work]')?.dataset.selectedWork === selectedKey && oldSvg?.dataset.graphLayout === graphDirection ? { left: oldCanvas.scrollLeft, top: oldCanvas.scrollTop } : null;
373
- document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab, canvas: !!config.canvas, direction: graphDirection });
496
+ document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab, canvas: !!config.canvas, direction: graphDirection, observationProject: snapshotProject });
374
497
  document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
375
498
  if (graphScroll) {
376
499
  const canvas = document.querySelector?.('.execution-graph-canvas'), svg = canvas?.querySelector('svg');
377
500
  if (svg && Number.isFinite(oldZoom) && oldZoom > 0) { const bounds = svg.viewBox.baseVal; svg.dataset.zoom = String(oldZoom); svg.setAttribute('width', String(bounds.width * oldZoom)); svg.setAttribute('height', String(bounds.height * oldZoom)); canvas.scrollLeft = graphScroll.left; canvas.scrollTop = graphScroll.top; }
378
501
  }
502
+ for (const detail of document.querySelectorAll('[data-observation-key]')) if (expandedHosts.includes(detail.dataset.observationKey)) detail.open = true;
379
503
  for (const card of document.querySelectorAll('[data-entry-key]')) for (const detail of card.querySelectorAll('details'))
380
504
  if (expanded.includes(`${card.dataset.entryKey}:${detail.dataset.workDetail}`)) detail.open = true;
381
505
  if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
@@ -409,6 +533,8 @@
409
533
  });
410
534
  for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
411
535
  if (config.canvas && snapshotProject !== config.project()) return;
536
+ const pageButton = event.target.closest('[data-host-graph-next]');
537
+ if (pageButton?.dataset.hostGraphNext) { nextObservationPage(pageButton); return; }
412
538
  if (graphInteraction(event.target)) return;
413
539
  if (!snapshot || (config.canvas && snapshotProject !== config.project())) return;
414
540
  if (config.canvas) {
@@ -438,7 +564,9 @@
438
564
  });
439
565
  document.getElementById('work-list').addEventListener('keydown', event => {
440
566
  if (config.canvas && snapshotProject !== config.project()) return;
441
- if (['Enter', ' '].includes(event.key) && event.target.closest('[data-graph-node]')) { event.preventDefault(); graphInteraction(event.target); return; }
567
+ const graphFind = event.target.closest('[data-graph-find]');
568
+ if (event.key === 'Enter' && graphFind?.dataset.graphFind === 'query') { event.preventDefault(); focusGraphMatch(graphFind.closest('.execution-graph'), graphFind.value); return; }
569
+ if (['Enter', ' '].includes(event.key) && event.target.closest('[data-graph-node]')?.dataset.graphNode) { event.preventDefault(); graphInteraction(event.target); return; }
442
570
  const button = event.target.closest('[data-work-tab]');
443
571
  if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
444
572
  const tabs = ['overview', 'execution', 'evidence'], current = tabs.indexOf(button.dataset.workTab);
@@ -61,6 +61,7 @@ try {
61
61
  if (command === 'start') {
62
62
  const root = realpathSync(resolve(value('--dir') || '.'));
63
63
  const taskId = value('--task-id');
64
+ const observeHostGraph = args.includes('--observe-graph');
64
65
  const allowed = (value('--allow') || '').split(',').map(p => p.trim()).filter(Boolean);
65
66
  let acceptance = [], intent = value('--intent') || 'delivery', maxAttempts = value('--max-attempts') === null ? 3 : Number(value('--max-attempts'));
66
67
  if (taskId) {
@@ -119,6 +120,7 @@ try {
119
120
  state = newRun({ root, prompt: value('--prompt'), checkPolicy, releasePolicy, specialistPolicy, domainPolicy,
120
121
  allowed, intent, entry: intent === 'research' ? 'project-auditor' : value('--entry') || 'product-owner',
121
122
  maxAttempts, hostRoutes, gatePolicy });
123
+ state.observeHostGraph = observeHostGraph;
122
124
  state.taskId = taskId || work.task.taskId;
123
125
  state.acceptance = acceptance;
124
126
  if (args.includes('--benchmark-trial')) {
@@ -64,13 +64,15 @@ export const DEFAULT_LEVEL = 'gates-only';
64
64
 
65
65
  /**
66
66
  * Archetypes where a compliance/security sign-off is not the operator's to skip.
67
- * SKILL.md already states these carry a `strict` minimum; encoding it here means
68
- * a new level cannot become a bypass by omission.
67
+ * The floor is gate:security + gate:compliance (with gate:ship) at every level, so
68
+ * a new level cannot become a bypass by omission. It is not `strict`: it adds no
69
+ * gate:code. reference/approval-level.md lists this set and a test holds the two equal.
69
70
  */
70
71
  const REGULATED = new Set([
71
72
  'ai-system', 'agent-product', 'commerce', 'web3', 'iot-embedded', 'regulated',
72
73
  'fintech', 'healthcare', 'insurance', 'legal', 'gov-public', 'defense-govcon',
73
74
  ]);
75
+ export const REGULATED_ARCHETYPES = Object.freeze([...REGULATED]);
74
76
  const REGULATED_FLOOR = ['security', 'compliance'];
75
77
 
76
78
  /**
@@ -1,5 +1,6 @@
1
1
  /** Restricted Claude Code subprocess for controller-owned, read-only proposals. */
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
+ import { performance } from 'node:perf_hooks';
3
4
 
4
5
  const PROPOSAL_SCHEMA = JSON.stringify({
5
6
  type: 'object',
@@ -35,24 +36,56 @@ export function parseClaudeResult(raw) {
35
36
  usage: body.usage || null, model: body.model || null };
36
37
  }
37
38
 
38
- export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.env.GREAT_CTO_CLAUDE_BIN || 'claude' }) {
39
+ export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.env.GREAT_CTO_CLAUDE_BIN || 'claude', onEvent = null }) {
39
40
  return new Promise(resolve => {
41
+ const deadline = performance.now() + timeoutMs;
40
42
  // Safe mode removes hooks, skills, plugins and project instructions. Restricted
41
43
  // mode confines file tools to cwd. The tools allow only inspection; the
42
44
  // controller remains the sole writer and gate owner.
43
- const args = ['--print', '--output-format', 'json', '--no-session-persistence',
45
+ const streaming = typeof onEvent === 'function';
46
+ const args = ['--print', '--output-format', streaming ? 'stream-json' : 'json', ...(streaming ? ['--verbose'] : []), '--no-session-persistence',
44
47
  '--safe-mode', '--restricted', '--strict-mcp-config', '--tools', 'Read,Glob,Grep',
45
48
  '--permission-mode', 'dontAsk', '--permission-prompts', 'none', '--json-schema', PROPOSAL_SCHEMA];
46
49
  const group = process.platform !== 'win32';
47
50
  const proc = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: group });
48
51
  let out = '', err = '', settled = false, timedOut = false, stdinError = null;
52
+ let observationHealth = 'ok', line = '', dropping = false;
53
+ const observationLimit = 1024 * 1024;
54
+ const observeLine = value => {
55
+ if (!value.trim()) return;
56
+ try {
57
+ const event = JSON.parse(value);
58
+ if (!event || typeof event !== 'object' || Array.isArray(event)) { observationHealth = 'degraded'; return; }
59
+ const pending = onEvent(event);
60
+ if (pending && typeof pending.then === 'function') {
61
+ // Observations are synchronous: do not create an untracked writer lifetime.
62
+ observationHealth = 'degraded'; Promise.resolve(pending).catch(() => {});
63
+ }
64
+ } catch { observationHealth = 'degraded'; }
65
+ };
66
+ const observeChunk = chunk => {
67
+ let offset = 0;
68
+ while (offset < chunk.length) {
69
+ const newline = chunk.indexOf('\n', offset), end = newline < 0 ? chunk.length : newline;
70
+ if (!dropping) {
71
+ const part = chunk.slice(offset, end);
72
+ if (Buffer.byteLength(line, 'utf8') + Buffer.byteLength(part, 'utf8') > observationLimit) {
73
+ line = ''; dropping = true; observationHealth = 'degraded';
74
+ } else line += part;
75
+ }
76
+ if (newline < 0) break;
77
+ if (!dropping) observeLine(line);
78
+ line = ''; dropping = false; offset = newline + 1;
79
+ }
80
+ };
49
81
  const finish = result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
50
82
  const kill = () => {
51
83
  try { if (group && proc.pid) process.kill(-proc.pid, 'SIGKILL'); else proc.kill('SIGKILL'); }
52
84
  catch { try { proc.kill('SIGKILL'); } catch { /* process already exited */ } }
53
85
  };
54
86
  const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
55
- proc.stdout.on('data', bytes => { out += String(bytes); });
87
+ proc.stdout.setEncoding('utf8');
88
+ proc.stdout.on('data', bytes => { out += bytes; if (streaming) observeChunk(bytes); });
56
89
  proc.stderr.on('data', bytes => { err += String(bytes); });
57
90
  const inputFailed = error => {
58
91
  stdinError ||= typeof error?.code === 'string' ? error.code : 'STDIN_ERROR';
@@ -62,14 +95,24 @@ export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.e
62
95
  proc.on('error', error => finish({ state: 'unreadable', finalText: null, errors: [String(error.message)], usage: null, code: null }));
63
96
  proc.on('close', code => {
64
97
  if (group && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* gone */ } }
65
- const parsed = parseClaudeResult(out);
98
+ if (streaming && line && !dropping) observeLine(line);
99
+ // Final execution parsing is independent of the bounded observer and its callback.
100
+ let final = out;
101
+ if (streaming) {
102
+ final = '';
103
+ for (const candidate of out.split('\n')) {
104
+ try { if (JSON.parse(candidate)?.type === 'result') final = candidate; } catch { /* observer reports malformed lines */ }
105
+ }
106
+ }
107
+ const parsed = parseClaudeResult(final);
66
108
  if (err.trim()) parsed.errors.push(err.trim().slice(-4000));
67
109
  if (stdinError) {
68
110
  parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null;
69
111
  parsed.errors.push(`prompt transport failed: ${stdinError}`);
70
112
  }
71
- if (timedOut) { parsed.state = 'unreadable'; parsed.errors.push(`timed out after ${timeoutMs}ms`); }
72
- finish({ ...parsed, code, timedOut });
113
+ if (performance.now() >= deadline) timedOut = true;
114
+ if (timedOut) { parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null; parsed.errors.push(`timed out after ${timeoutMs}ms`); }
115
+ finish({ ...parsed, code, timedOut, ...(streaming ? { observationHealth } : {}) });
73
116
  });
74
117
  try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
75
118
  });
@@ -13,6 +13,7 @@
13
13
  * available version, auth mode and the model it will run, read from disk
14
14
  */
15
15
  import { spawn, spawnSync } from 'node:child_process';
16
+ import { performance } from 'node:perf_hooks';
16
17
  import { existsSync, readFileSync } from 'node:fs';
17
18
  import { homedir } from 'node:os';
18
19
  import { join } from 'node:path';
@@ -115,6 +116,7 @@ export function runCodexExec({
115
116
  sandbox = 'read-only', ephemeral = true, extraArgs = [], onEvent = null,
116
117
  }) {
117
118
  return new Promise((resolve) => {
119
+ const deadline = performance.now() + timeoutMs;
118
120
  const args = ['exec', '--json', '--skip-git-repo-check'];
119
121
  if (sandbox) args.push('-s', sandbox);
120
122
  if (ephemeral) args.push('--ephemeral');
@@ -139,24 +141,41 @@ export function runCodexExec({
139
141
  };
140
142
  const timer = setTimeout(() => { timedOut = true; killGroup(); }, timeoutMs);
141
143
 
142
- // Each JSON event is handed to onEvent as it arrives, so a watcher sees a tool
143
- // call while the run is still going. A listener that throws is its own bug:
144
- // it must not fail the run, and the final parse below is unchanged.
145
- let pending = '';
146
- proc.stdout.on('data', (b) => {
147
- const chunk = String(b);
148
- out += chunk;
149
- if (typeof onEvent !== 'function') return;
150
- pending += chunk;
151
- let nl;
152
- while ((nl = pending.indexOf('\n')) >= 0) {
153
- const line = pending.slice(0, nl).trim();
154
- pending = pending.slice(nl + 1);
155
- if (!line.startsWith('{')) continue;
156
- let ev;
157
- try { ev = JSON.parse(line); } catch { continue; }
158
- try { onEvent(ev); } catch { /* a failing listener must not fail the run */ }
144
+ // Observation failures remain separate from the final execution parser.
145
+ const observing = typeof onEvent === 'function';
146
+ let observationHealth = 'ok', pending = '', dropping = false;
147
+ const observationLimit = 1024 * 1024;
148
+ const observeLine = line => {
149
+ const text = line.trim();
150
+ if (!text.startsWith('{') && !text.startsWith('[')) return;
151
+ try {
152
+ const event = JSON.parse(text);
153
+ if (!event || typeof event !== 'object' || Array.isArray(event)) { observationHealth = 'degraded'; return; }
154
+ const result = onEvent(event);
155
+ if (result && typeof result.then === 'function') {
156
+ observationHealth = 'degraded'; Promise.resolve(result).catch(() => {});
157
+ }
158
+ } catch { observationHealth = 'degraded'; }
159
+ };
160
+ const observeChunk = chunk => {
161
+ let offset = 0;
162
+ while (offset < chunk.length) {
163
+ const newline = chunk.indexOf('\n', offset), end = newline < 0 ? chunk.length : newline;
164
+ if (!dropping) {
165
+ const part = chunk.slice(offset, end);
166
+ if (Buffer.byteLength(pending, 'utf8') + Buffer.byteLength(part, 'utf8') > observationLimit) {
167
+ pending = ''; dropping = true; observationHealth = 'degraded';
168
+ } else pending += part;
169
+ }
170
+ if (newline < 0) break;
171
+ if (!dropping) observeLine(pending);
172
+ pending = ''; dropping = false; offset = newline + 1;
159
173
  }
174
+ };
175
+ proc.stdout.setEncoding('utf8');
176
+ proc.stdout.on('data', chunk => {
177
+ out += chunk;
178
+ if (observing) observeChunk(chunk);
160
179
  });
161
180
  proc.stderr.on('data', (b) => { err += String(b); });
162
181
  const inputFailed = error => {
@@ -174,6 +193,7 @@ export function runCodexExec({
174
193
  clearTimeout(timer);
175
194
  // Best effort: a child the CLI left behind must not outlive the review.
176
195
  if (group && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* group already gone */ } }
196
+ if (observing && pending && !dropping) observeLine(pending);
177
197
  const parsed = parseCodexStream(out);
178
198
  // stderr is kept even on success: Codex writes warnings there that change
179
199
  // how a result should be read.
@@ -188,11 +208,15 @@ export function runCodexExec({
188
208
  }
189
209
  // A run cut off by the clock may have printed a verdict before it was done.
190
210
  // That is a truncated answer, and a truncated answer is not an answer.
211
+ const returnedAfterDeadline = performance.now() >= deadline;
212
+ if (returnedAfterDeadline && !timedOut) parsed.text = null;
213
+ if (returnedAfterDeadline) timedOut = true;
191
214
  if (timedOut) {
215
+ parsed.finalText = null;
192
216
  parsed.errors.push(`timed out after ${timeoutMs}ms`);
193
217
  if (parsed.state === 'ok') parsed.state = 'unreadable';
194
218
  }
195
- resolve({ ...parsed, code, model, timedOut });
219
+ resolve({ ...parsed, code, model, timedOut, ...(observing ? { observationHealth } : {}) });
196
220
  });
197
221
 
198
222
  try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
@@ -7,11 +7,22 @@ import { detectCodex } from './codex-exec.mjs';
7
7
  import { detectClaude } from './claude-exec.mjs';
8
8
  import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
9
9
  import { projectExecutionTrace } from './execution-trace.mjs';
10
+ import { graphBinding, projectHostObservations } from './host-graph-observer.mjs';
11
+ import { invocationIdentity } from './invocation-identity.mjs';
10
12
 
11
13
  export const codexRunStore = ({ home = homedir(), env = process.env } = {}) =>
12
14
  env.GREAT_CTO_CODEX_RUNS_DIR || join(home, '.great_cto', 'codex-runs');
13
15
 
14
16
  export function projectCodexState(state) {
17
+ const observationInvocations = (state.attempts || []).flatMap(a => [{ ...a, observationHost: a.host || 'codex', observationId: a.invocationId }, ...(a.verifierInvocationId ? [{ ...a, observationHost: 'codex', observationId: a.verifierInvocationId }] : [])]).filter(a => a.observationId);
18
+ // A frozen wave is persisted before dispatch; prepared attempts appear later.
19
+ for (const role of state.wave?.roles || []) {
20
+ const id = state.wave.attemptIds?.[role], observationHost = state.wave.hosts?.[role];
21
+ const observationId = invocationIdentity({ session_id: state.id, agent_id: id });
22
+ if (observationId && ['codex', 'claude-code'].includes(observationHost)
23
+ && !observationInvocations.some(a => a.observationId === observationId && a.observationHost === observationHost))
24
+ observationInvocations.push({ id, observationHost, observationId });
25
+ }
15
26
  return {
16
27
  id: state.id,
17
28
  version: state.version,
@@ -23,11 +34,16 @@ export function projectCodexState(state) {
23
34
  hostRoutes: state.hostRoutes || {},
24
35
  controllerDispatch: dispatchEvidenceSummary(state),
25
36
  executionGraph: projectExecutionTrace(state),
37
+ hostObservationHealth: state.hostObservationHealth || null,
38
+ hostObservationBindings: observationInvocations.map(a => ({ attemptId: a.id, sessionId: a.observationId, host: a.observationHost })),
39
+ hostObservationOmittedAttempts: Math.max(0, observationInvocations.length - 8),
40
+ hostObservations: state.observeHostGraph === true ? observationInvocations.slice(-8).map(a =>
41
+ projectHostObservations(graphBinding({ root: state.root, runId: state.id, sessionId: a.observationId, attemptId: a.id, host: a.observationHost }))) : [],
26
42
  wave: state.wave ? { id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
27
43
  rolesCompleted: Object.keys(state.results || {}),
28
- attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId }) =>
44
+ attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId, verifierInvocationId }) =>
29
45
  ({ id, role, host: host || 'codex', number, phase, status, startedAt, finishedAt,
30
- ...(invocationId ? { invocationId } : {}) })),
46
+ ...(invocationId ? { invocationId } : {}), ...(verifierInvocationId ? { verifierInvocationId } : {}) })),
31
47
  pending: state.pending ? { role: state.pending.role, gates: state.pending.gates || [], createdAt: state.pending.createdAt } : null,
32
48
  release: state.release ? {
33
49
  adapter: state.release.adapter,