great-cto 3.28.8 → 3.29.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.
@@ -1582,6 +1582,28 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1582
1582
  border-radius: 0;
1583
1583
  overflow-x: auto;
1584
1584
  }
1585
+ .agent-activity { margin-top: 8px; font-size: var(--fs-small); }
1586
+ .agent-activity .aa-row { display: flex; gap: 10px; align-items: baseline; padding: 2px 16px; }
1587
+ /* ADR-023: what each agent turn changed. */
1588
+ .turns-panel { margin: 8px 16px 0; font-size: var(--fs-small); }
1589
+ .turns-panel > summary { cursor: pointer; color: var(--text2); }
1590
+ .turns-panel .tp-session { margin-top: 6px; }
1591
+ .turns-panel .tp-turns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
1592
+ .turns-panel .tp-turn { font: inherit; padding: 1px 6px; border: 1px solid var(--border); border-radius: 4px; background: transparent; cursor: pointer; }
1593
+ .turns-panel .tp-diff { margin-top: 8px; max-height: 420px; overflow: auto; white-space: pre; font-family: var(--mono); font-size: 12px; padding: 8px; border: 1px solid var(--border); border-radius: 4px; }
1594
+ .turns-panel .tp-add { color: var(--status-done); }
1595
+ .turns-panel .tp-del { color: var(--status-blocked); }
1596
+ /* The panel reuses the agent strip's state classes, which are scoped to .agent-activity. */
1597
+ .turns-panel .aa-time { font-family: var(--mono); opacity: 0.7; }
1598
+ .turns-panel .aa-row { padding: 2px 0; }
1599
+ .turns-panel .aa-empty { padding: 2px 0; opacity: 0.75; }
1600
+ .turns-panel .aa-bad { color: var(--status-blocked); }
1601
+ .turns-panel .aa-attn { color: var(--status-progress); }
1602
+ .agent-activity .aa-time { font-family: var(--mono); opacity: 0.7; min-width: 64px; }
1603
+ .agent-activity .aa-ok { color: var(--status-done); }
1604
+ .agent-activity .aa-bad { color: var(--status-blocked); }
1605
+ .agent-activity .aa-attn { color: var(--status-progress); }
1606
+ .agent-activity .aa-empty { padding: 2px 16px; opacity: 0.75; }
1585
1607
  .pl-stage {
1586
1608
  /* `flex: 1` let a cell shrink below its own label, and `.pl-name` below
1587
1609
  refuses to wrap — so the text simply ran over its neighbour. Measured at
@@ -3424,6 +3446,14 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3424
3446
  <span class="tier-badge" id="harness-badge" title="which harness gives the second opinion in review — from capabilities: second_opinion in PROJECT.md"></span>
3425
3447
  </div>
3426
3448
  <div class="pipeline-track" id="pipeline-track"></div>
3449
+ <!-- ADR-021: what agents are doing, streamed as it happens. -->
3450
+ <div class="agent-activity" id="agent-activity" aria-live="polite"></div>
3451
+ <!-- ADR-023: what each agent turn changed, from the turn snapshots. -->
3452
+ <details class="turns-panel" id="turns-panel">
3453
+ <summary>Turns — what each agent turn changed</summary>
3454
+ <div id="turns-body"></div>
3455
+ <div id="turn-diff" aria-live="polite"></div>
3456
+ </details>
3427
3457
  </div>
3428
3458
 
3429
3459
  <!-- Harnesses: the host that runs the pipeline, and the second harness
@@ -4136,7 +4166,24 @@ function renderInbox(d) {
4136
4166
  function renderStatusVerdict(d, ageH) {
4137
4167
  const el = document.getElementById('proj-status-verdict');
4138
4168
  if (!el) return;
4169
+ // Kept so the agent activity strip, which loads after this line is first drawn,
4170
+ // can redraw it.
4171
+ window.__statusVerdictArgs = [d, ageH];
4172
+ // ADR-021: agent events count as activity, and a stage that did not chain is
4173
+ // said on this collapsed line. The strip sits inside the closed disclosure, and
4174
+ // a warning behind a closed disclosure is as unseen as the journal line it
4175
+ // replaced — the first look at the page showed "no activity recorded" beside
4176
+ // five events and a stalled stage.
4177
+ const aa = window.__agentActivity;
4178
+ const events = aa && aa.state === 'live' && Array.isArray(aa.events) ? aa.events : [];
4179
+ const lastEvent = events.length ? Date.parse(events[events.length - 1].ts) : NaN;
4180
+ if (Number.isFinite(lastEvent)) {
4181
+ const eventH = Math.max(0, (Date.now() - lastEvent) / 3600000);
4182
+ if (ageH == null || eventH < ageH) ageH = eventH;
4183
+ }
4139
4184
  const parts = [];
4185
+ const stalled = aa && Array.isArray(aa.attention) ? aa.attention.length : 0;
4186
+ if (stalled) parts.push(`⚠ ${stalled} stage${stalled > 1 ? 's' : ''} did not chain`);
4140
4187
  if (ageH == null) parts.push('no activity recorded');
4141
4188
  else if (ageH < 1) parts.push(`last activity ${Math.max(1, Math.round(ageH * 60))}m ago`);
4142
4189
  else if (ageH < 48) parts.push(`last activity ${Math.round(ageH)}h ago`);
@@ -5589,6 +5636,136 @@ async function refreshPipeline() {
5589
5636
  if (Array.isArray(d)) renderPipeline(d);
5590
5637
  renderTierBadge();
5591
5638
  renderHarnesses();
5639
+ loadAgentActivity();
5640
+ loadTurns();
5641
+ }
5642
+
5643
+ // ADR-021 phase 1: what agents are doing, streamed as it happens. Three states,
5644
+ // and each says which: no events recorded is not an idle agent, and events the
5645
+ // board could not read are "not measured", never an empty strip.
5646
+ // ADR-023: the turns a project's agents took, and what each one changed.
5647
+ async function loadTurns() {
5648
+ const root = document.getElementById('turns-body');
5649
+ if (!root) return;
5650
+ const snap = await api(`/api/turns${pqs()}`).catch(() => null);
5651
+ root.innerHTML = renderTurns(snap);
5652
+ }
5653
+
5654
+ function renderTurns(snap) {
5655
+ // Three states, never an empty list that could mean either "nothing" or "could not read".
5656
+ if (!snap) return `<div class="aa-empty aa-bad">Turns not measured: the board could not load them.</div>`;
5657
+ if (snap.state === 'none') return `<div class="aa-empty">No turn history: ${esc(snap.why || 'not available for this project')}</div>`;
5658
+ if (snap.state === 'unreadable') return `<div class="aa-empty aa-bad">Turns not measured: ${esc(snap.why || 'git refused')}</div>`;
5659
+ const sessions = Array.isArray(snap.sessions) ? snap.sessions : [];
5660
+ if (!sessions.length) return `<div class="aa-empty">No turn snapshots yet — they start with the next agent turn.</div>`;
5661
+ const when = (ms) => { try { return ms ? new Date(ms).toLocaleString([], { hour12: false }) : ''; } catch { return ''; } };
5662
+ return sessions.map((s) => {
5663
+ const count = Math.min(Number(s.turns) || 0, 50);
5664
+ const first = Math.max(0, (Number(s.newestTurn) || 0) - count + 1);
5665
+ const buttons = [];
5666
+ for (let n = Number(s.newestTurn) || 0; n >= first; n--) {
5667
+ // The session goes in as a JSON string, escaped for the attribute: esc() turns '
5668
+ // into &#39;, which the browser decodes before the handler runs, so a quoted
5669
+ // string built from esc() alone could be broken out of.
5670
+ buttons.push(`<button type="button" class="tp-turn" onclick="showTurnDiff(${esc(JSON.stringify(String(s.session)))}, ${n})">${n}</button>`);
5671
+ }
5672
+ return `<div class="tp-session"><span class="aa-time">${esc(when(s.newestAt))}</span> <code>${esc(String(s.session).slice(0, 8))}</code> · ${esc(String(s.turns))} turn(s)`
5673
+ + `<div class="tp-turns">${buttons.join('')}</div></div>`;
5674
+ }).join('');
5675
+ }
5676
+
5677
+ async function showTurnDiff(session, turn) {
5678
+ const root = document.getElementById('turn-diff');
5679
+ if (!root) return;
5680
+ root.innerHTML = `<div class="aa-empty">Loading turn ${esc(String(turn))}…</div>`;
5681
+ const q = pqs() ? `&${pqs().slice(1)}` : '';
5682
+ const d = await api(`/api/turns/diff?session=${encodeURIComponent(session)}&turn=${encodeURIComponent(turn)}${q}`).catch(() => null);
5683
+ root.innerHTML = renderTurnDiff(d);
5684
+ }
5685
+
5686
+ function renderTurnDiff(d) {
5687
+ // A diff is file content: every line is escaped before it is drawn.
5688
+ if (!d || d.state !== 'ok') return `<div class="aa-empty aa-bad">This turn could not be shown: ${esc((d && d.why) || 'the board could not load it')}</div>`;
5689
+ const paths = (Array.isArray(d.paths) ? d.paths : []).map((p) => `<code>${esc(p)}</code>`).join(', ') || 'no files changed';
5690
+ const lines = String(d.patch || '').split('\n').map((l) => {
5691
+ const cls = l.startsWith('+') && !l.startsWith('+++') ? 'tp-add' : l.startsWith('-') && !l.startsWith('---') ? 'tp-del' : '';
5692
+ return cls ? `<span class="${cls}">${esc(l)}</span>` : esc(l);
5693
+ }).join('\n');
5694
+ const cut = d.truncated ? `<div class="aa-row aa-attn">⚠ The diff was cut at the size limit — the file list above is complete.</div>` : '';
5695
+ return `<div class="aa-row">Changed: ${paths}</div>${cut}<div class="tp-diff">${lines}</div>`;
5696
+ }
5697
+
5698
+ async function loadAgentActivity() {
5699
+ // Once the stream holds a cursor for this project it owns the strip: a GET
5700
+ // snapshot landing after its deltas would show those events twice.
5701
+ const c = window.__agentCursor;
5702
+ if (c && c.project === currentProject && window.__agentActivity) return;
5703
+ const snap = await api(`/api/agent-events${pqs()}`).catch(() => null);
5704
+ renderAgentActivity(snap);
5705
+ }
5706
+
5707
+ // ADR-021 phase 2: the stream resumes. A delta appends to what the strip holds; a
5708
+ // snapshot replaces it (and one that skipped events keeps saying so until the next).
5709
+ function mergeAgentActivity(prev, frame) {
5710
+ if (!frame) return prev || null;
5711
+ const cap = (a, n) => (Array.isArray(a) ? a : []).slice(-n);
5712
+ if (frame.mode !== 'delta') return frame;
5713
+ if (!prev || prev.state !== 'live') return { ...frame, events: cap(frame.events, 50), attention: cap(frame.attention, 3) };
5714
+ return {
5715
+ ...frame,
5716
+ events: cap([...(prev.events || []), ...(frame.events || [])], 50),
5717
+ attention: cap([...(prev.attention || []), ...(frame.attention || [])], 3),
5718
+ gap: prev.gap === true,
5719
+ };
5720
+ }
5721
+
5722
+ // The cursor belongs to one project's events file; another project's file has other offsets.
5723
+ function agentSinceParam() {
5724
+ const c = window.__agentCursor;
5725
+ return c && c.id && c.project === currentProject ? `since=${encodeURIComponent(c.id)}` : '';
5726
+ }
5727
+
5728
+ function renderAgentActivity(snap) {
5729
+ // Stored before anything else, so the collapsed status line can say what the
5730
+ // strip knows even when the strip itself is not on the page.
5731
+ window.__agentActivity = snap;
5732
+ if (window.__statusVerdictArgs) {
5733
+ try { renderStatusVerdict(...window.__statusVerdictArgs); } catch { /* the strip still renders */ }
5734
+ }
5735
+ const root = document.getElementById('agent-activity');
5736
+ if (!root) return;
5737
+ if (!snap) {
5738
+ root.innerHTML = `<div class="aa-empty aa-bad">Agent events not measured: the board could not load them.</div>`;
5739
+ return;
5740
+ }
5741
+ if (snap.state === 'unreadable') {
5742
+ root.innerHTML = `<div class="aa-empty aa-bad">Agent events not measured: ${esc(snap.why || 'the events file could not be read')}</div>`;
5743
+ return;
5744
+ }
5745
+ const events = Array.isArray(snap.events) ? snap.events : [];
5746
+ if (snap.state === 'none' || !events.length) {
5747
+ root.innerHTML = `<div class="aa-empty">No agent events recorded yet — they start with the next agent run.</div>`;
5748
+ return;
5749
+ }
5750
+ const time = (ts) => { try { return new Date(ts).toLocaleTimeString([], { hour12: false }); } catch { return ''; } };
5751
+ const what = (e) => {
5752
+ if (e.kind === 'tool') {
5753
+ const p = Array.isArray(e.paths) && e.paths.length ? ` ${esc(e.paths[0])}${e.paths.length > 1 ? ` +${e.paths.length - 1}` : ''}` : '';
5754
+ return `${esc(e.tool || 'tool')}${p}`;
5755
+ }
5756
+ if (e.kind === 'pipeline') return `pipeline: ${esc(e.outcome || '')}${e.verdict ? ' · ' + esc(e.verdict) : ''}`;
5757
+ return esc(e.kind || '');
5758
+ };
5759
+ const attention = (Array.isArray(snap.attention) ? snap.attention : []).map((e) =>
5760
+ `<div class="aa-row aa-attn" title="The pipeline did not move on after this stage">⚠ <span>${esc(e.agent || 'stage')}: ${esc(e.outcome || '')} — the pipeline did not chain</span></div>`).join('');
5761
+ const rows = events.slice(-8).reverse().map((e) => {
5762
+ const mark = e.ok === true ? '<span class="aa-ok">✓</span>' : e.ok === false ? '<span class="aa-bad">✗</span>' : '';
5763
+ return `<div class="aa-row"><span class="aa-time">${esc(time(e.ts))}</span><span>${esc(e.agent || '')}</span><span>${what(e)}</span>${mark}</div>`;
5764
+ }).join('');
5765
+ const gap = snap.gap === true
5766
+ ? `<div class="aa-row aa-attn" title="More events arrived while the board was disconnected than a resume sends">⚠ <span>Some agent events were skipped while the board was disconnected — showing the newest.</span></div>`
5767
+ : '';
5768
+ root.innerHTML = gap + attention + rows;
5592
5769
  }
5593
5770
 
5594
5771
  // change_tier badge — the gate + judge plan for the current working-tree diff (ADR-003/004).
@@ -7125,7 +7302,10 @@ function api(url, opts) {
7125
7302
 
7126
7303
  function connectSSE() {
7127
7304
  if (window._gctoSSE) { try { window._gctoSSE.close(); } catch {} }
7128
- const es = new EventSource(`/api/sse${pqs()}`);
7305
+ // This page recreates its EventSource after an error, so the browser never sends
7306
+ // Last-Event-ID by itself: hand the agent cursor back explicitly.
7307
+ const since = agentSinceParam();
7308
+ const es = new EventSource(`/api/sse${pqs()}${since ? (pqs() ? '&' : '?') + since : ''}`);
7129
7309
  window._gctoSSE = es;
7130
7310
  const applyTasksSnapshot = (newTasks) => {
7131
7311
  // Detect status transitions for toast feed
@@ -7177,6 +7357,12 @@ function connectSSE() {
7177
7357
  renderPipeline(stages);
7178
7358
  } catch {}
7179
7359
  });
7360
+ es.addEventListener('agent', e => {
7361
+ try {
7362
+ if (e.lastEventId) window.__agentCursor = { project: currentProject, id: e.lastEventId };
7363
+ renderAgentActivity(mergeAgentActivity(window.__agentActivity, JSON.parse(e.data)));
7364
+ } catch {}
7365
+ });
7180
7366
  es.addEventListener('inbox', e => {
7181
7367
  try { renderInbox(JSON.parse(e.data)); } catch {}
7182
7368
  });
@@ -7434,48 +7620,37 @@ function approveConsequence(id, reversibility) {
7434
7620
  return lines.join('\n');
7435
7621
  }
7436
7622
 
7437
- async function gateAction(id, action, runAgent) {
7623
+ async function gateAction(id, action) {
7624
+ // ADR-024 §1: the server refuses a decision without the token the inbox issued for
7625
+ // this gate, and an approval of an expensive gate without its typed name. The page
7626
+ // sends both; it no longer decides alone.
7627
+ const token = ((window.__inboxData && window.__inboxData.pending_gates) || []).find((g) => g.id === id)?.token || null;
7438
7628
  let body;
7439
7629
  if (action === 'reject') {
7440
7630
  const reason = prompt(`Reject ${id}. Reason (optional)?`);
7441
7631
  if (reason === null) return;
7442
- body = { action, reason, project: currentProject };
7632
+ body = { action, reason, token, project: currentProject };
7443
7633
  } else {
7444
7634
  // The gate is the human signature the pipeline waits on. Rejecting has
7445
7635
  // always asked for a reason and approving asked for nothing, so the cheaper
7446
7636
  // click was the one that advanced work nobody can un-advance — friction
7447
7637
  // pointing exactly the wrong way.
7448
7638
  //
7449
- // Medium cost-of-undo (ADR-009): one confirm that NAMES the consequence, not
7450
- // a typed-name ritual on a button pressed several times a day. The runAgent
7451
- // path already shows an editable prompt that says what it will approve and
7452
- // run, and is cancellable — a second dialog there would be pure friction.
7453
- // Approve ritual follows cost of undo (DESIGN §6.2). `routine`: one confirm
7454
- // naming the consequence. `expensive` and `unclassified`: the operator types
7455
- // the gate name — exact, case-sensitive — before the approval is posted; an
7456
- // unclassified gate gets the expensive ritual because nobody judged it cheap.
7639
+ // Approve ritual follows cost of undo (ADR-009, DESIGN §6.2). `routine`: one
7640
+ // confirm naming the consequence. `expensive` and `unclassified`: the operator
7641
+ // types the gate name — exact, case-sensitive — and the server checks the same
7642
+ // name; an unclassified gate gets the expensive ritual because nobody judged it
7643
+ // cheap.
7457
7644
  const rev = GATE_REVERSIBILITY[id] || null;
7458
7645
  const guarded = rev && (rev.state === 'expensive' || rev.state === 'unclassified');
7459
- if (!runAgent && guarded) {
7646
+ body = { action, token, project: currentProject };
7647
+ if (guarded) {
7460
7648
  const gateName = rev.gate ? `gate:${rev.gate}` : id;
7461
7649
  const typed = prompt(`${approveConsequence(id, rev)}\n\nType ${gateName} to approve:`, '');
7462
7650
  if (typed === null) return;
7463
7651
  if (typed.trim() !== gateName) { alert(`Not approved — "${typed.trim()}" is not ${gateName}.`); return; }
7464
- } else if (!runAgent && !confirm(approveConsequence(id, rev))) return; // routine
7465
- body = { action, project: currentProject };
7466
- if (runAgent) {
7467
- const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
7468
- // Prefer a ready-to-run prompt derived from the task's own body (title/description/labels),
7469
- // falling back to the generic one. The operator still sees and edits it before launch.
7470
- let dflt = generic;
7471
- try {
7472
- const pf = await api(`/api/agent/prompt-for?id=${encodeURIComponent(id)}${pqs().replace(/^\?/, '&')}`);
7473
- if (pf && pf.prompt) dflt = pf.prompt;
7474
- } catch (_) { /* fall back to generic */ }
7475
- const p = prompt(`Approve ${id} and run an agent in the project.\n\nPrompt for the agent (edit as needed):`, dflt);
7476
- if (p === null) return; // cancelled the whole action
7477
- body.runAgent = true; body.agentPrompt = p.trim() || dflt;
7478
- }
7652
+ body.confirm = typed.trim();
7653
+ } else if (!confirm(approveConsequence(id, rev))) return; // routine
7479
7654
  }
7480
7655
  const res = await api(`/api/gates/${encodeURIComponent(id)}${pqs()}`, {
7481
7656
  method: 'POST',
@@ -7484,6 +7659,8 @@ async function gateAction(id, action, runAgent) {
7484
7659
  });
7485
7660
  if (!res || res.error) {
7486
7661
  alert('Gate action failed: ' + (res?.error || 'network error'));
7662
+ // A refused token or a changed project: reload, so the gate is shown as it is now.
7663
+ api(`/api/inbox${pqs()}`).then(d => d && renderInbox(d));
7487
7664
  return;
7488
7665
  }
7489
7666
  showToast(`Gate <strong>${esc(id)}</strong> ${action === 'approve' ? '✓ approved' : '✗ rejected'}`, action === 'approve' ? 'success' : 'error', 3000);
@@ -14,10 +14,10 @@ import path from 'path';
14
14
  import { spawnSync } from 'child_process';
15
15
  import { PORT, PUBLIC, HOST } from './lib/config.mjs';
16
16
  import { warmTasksAsync } from './lib/beads.mjs';
17
- import { originAllowed, isInsideDir } from './lib/util.mjs';
17
+ import { originAllowed, hostAllowed, parseAllowedHosts, isInsideDir } from './lib/util.mjs';
18
18
  import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
19
19
  import { startAlertCron } from './lib/alerts.mjs';
20
- import { watchBeads, watchVerdicts } from './lib/watchers.mjs';
20
+ import { watchBeads, watchVerdicts, watchAgentEvents } from './lib/watchers.mjs';
21
21
  import { dispatch } from './lib/routes.mjs';
22
22
  import { log } from './lib/log.mjs';
23
23
 
@@ -39,6 +39,18 @@ const server = http.createServer(async (req, res) => {
39
39
  }
40
40
  });
41
41
  }
42
+ // ── Host allowlist (great_cto-xq9h) ─────────────────────────────────────────
43
+ // Before anything is read or written: a request addressed to a name the board
44
+ // does not know is a DNS-rebinding page, not the user. Reads count — the SSE
45
+ // stream and every GET carry the project's tasks, verdicts and costs.
46
+ if (!hostAllowed(req.headers.host)) {
47
+ res.writeHead(403, { 'Content-Type': 'application/json' });
48
+ res.end(JSON.stringify({
49
+ error: 'host not allowed — the board answers only on localhost, 127.0.0.1, its bind address, or a host listed in GREAT_CTO_ALLOWED_HOSTS',
50
+ }));
51
+ return;
52
+ }
53
+
42
54
  const url = new URL(req.url, `http://localhost:${PORT}`);
43
55
  const pathname = url.pathname;
44
56
  const proj = url.searchParams.get('project');
@@ -121,6 +133,10 @@ server.listen(PORT, HOST, () => {
121
133
  log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
122
134
  log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
123
135
  }
136
+ if (['0.0.0.0', '::'].includes(HOST) && parseAllowedHosts().length === 0) {
137
+ log.info(` ⚠ bound to ${HOST} with no GREAT_CTO_ALLOWED_HOSTS — only localhost / 127.0.0.1 names are answered.`);
138
+ log.info(` Reaching it by LAN address or through a tunnel? Set GREAT_CTO_ALLOWED_HOSTS=<host[:port]>,...`);
139
+ }
124
140
  // Warm the task cache for THIS project before a browser asks — asynchronously.
125
141
  //
126
142
  // `bd list` costs seconds. This used to call `getTasks(cwd)` directly, which
@@ -159,6 +175,7 @@ server.listen(PORT, HOST, () => {
159
175
  watchBeads();
160
176
  startAlertCron();
161
177
  watchVerdicts();
178
+ watchAgentEvents();
162
179
 
163
180
  // Auto-open browser unless --no-open
164
181
  if (!process.argv.includes('--no-open')) {
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, rmdirSync, realpathSync } from 'node:fs';
3
+ import { join, resolve, relative, sep, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { newRun, runStage, approve, recover, cancel } from './lib/codex-pipeline.mjs';
6
+ import { approveRelease } from './lib/codex-release.mjs';
7
+ import { codexRunStore, listCodexRuns, codexHostDoctor } from './lib/codex-host-state.mjs';
8
+
9
+ // State is outside the worker workspace. A per-run exclusive lock covers the entire subprocess lifetime.
10
+ const args = process.argv.slice(2);
11
+ const command = args.shift();
12
+ const value = name => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
13
+ const store = codexRunStore();
14
+ mkdirSync(store, { recursive: true, mode: 0o700 });
15
+ const save = state => {
16
+ const file = join(store, `${state.id}.json`);
17
+ writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
18
+ renameSync(`${file}.tmp`, file);
19
+ };
20
+ let locked = null;
21
+ try {
22
+ if (command === 'doctor') {
23
+ const result = codexHostDoctor({ pluginRoot: resolve(value('--plugin-root') || join(dirname(fileURLToPath(import.meta.url)), '..')), store });
24
+ console.log(JSON.stringify(result, null, 2)); process.exit(result.state === 'ready' ? 0 : 2);
25
+ }
26
+ if (command === 'list') {
27
+ console.log(JSON.stringify(listCodexRuns({ root: value('--dir') ? realpathSync(resolve(value('--dir'))) : null, store }), null, 2));
28
+ process.exit(0);
29
+ }
30
+ let state;
31
+ if (command === 'start') {
32
+ const root = realpathSync(resolve(value('--dir') || '.'));
33
+ let checkPolicy = null;
34
+ if (value('--checks-policy')) {
35
+ const policyPath = realpathSync(value('--checks-policy'));
36
+ const rel = relative(root, policyPath);
37
+ if (rel !== '..' && !rel.startsWith(`..${sep}`)) throw Error('checks policy must be operator-owned outside the target workspace');
38
+ checkPolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
39
+ }
40
+ let releasePolicy = null;
41
+ if (value('--release-policy')) {
42
+ const policyPath = realpathSync(value('--release-policy'));
43
+ const rel = relative(root, policyPath);
44
+ if (rel !== '..' && !rel.startsWith(`..${sep}`)) throw Error('release policy must be operator-owned outside the target workspace');
45
+ releasePolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
46
+ }
47
+ state = newRun({ root, prompt: value('--prompt'), checkPolicy, releasePolicy,
48
+ allowed: (value('--allow') || '').split(',').filter(Boolean), entry: value('--entry') || 'product-owner',
49
+ maxAttempts: value('--max-attempts') === null ? 3 : Number(value('--max-attempts')) });
50
+ save(state);
51
+ } else {
52
+ const id = args[0];
53
+ if (!/^[0-9a-f-]{36}$/.test(id || '')) throw Error('a run UUID is required');
54
+ state = JSON.parse(readFileSync(join(store, `${id}.json`), 'utf8'));
55
+ if (state.id !== id || state.version !== 1) throw Error('invalid run state');
56
+ }
57
+ if (!['start', 'resume', 'status', 'approve', 'approve-release', 'recover', 'cancel'].includes(command)) throw Error('expected start, resume, status, approve, approve-release, recover, cancel, list or doctor');
58
+ if (command !== 'status') {
59
+ const lock = join(store, `${state.id}.lock`);
60
+ mkdirSync(lock); locked = lock;
61
+ // Reload under lock so simultaneous approvals cannot overwrite one another.
62
+ state = JSON.parse(readFileSync(join(store, `${state.id}.json`), 'utf8'));
63
+ if (command === 'approve') { approve(state, value('--token')); save(state); }
64
+ else if (command === 'approve-release') { approveRelease(state, value('--token')); save(state); }
65
+ else if (command === 'recover') { recover(state); save(state); }
66
+ else if (command === 'cancel') { cancel(state); save(state); }
67
+ else while (state.status === 'ready') await runStage(state, { save });
68
+ }
69
+ console.log(JSON.stringify({ id: state.id, status: state.status, reason: state.reason,
70
+ release: state.release ? { status: state.release.status, token: state.release.token, adapter: state.release.adapter,
71
+ artifactDigest: state.release.artifactDigest, target: state.release.target, path: state.release.path, url: state.release.url,
72
+ activation: state.release.activation, rollback: state.release.rollback } : null,
73
+ pending: state.pending, queue: state.queue, rolesCompleted: Object.keys(state.results), stateFile: join(store, `${state.id}.json`) }, null, 2));
74
+ process.exitCode = ['blocked', 'manual-action', 'join-wait'].includes(state.status) ? 2 : 0;
75
+ } catch (error) {
76
+ console.error(`codex-pipeline: ${error.message}`);
77
+ process.exitCode = 2;
78
+ } finally {
79
+ if (locked) rmdirSync(locked);
80
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // agent-events — what agents are doing, recorded as it happens (ADR-021, phase 1).
3
+ //
4
+ // The board showed outcomes after the fact. On 2026-09-14 a live pipeline run
5
+ // stopped after its first stage and nothing said so: the only record was a
6
+ // journal line, found by reading the file by hand. The hooks the plugin already
7
+ // runs now append one line per event to <project>/.great_cto/events.jsonl, and the
8
+ // board streams the file.
9
+ //
10
+ // Three rules, each one the reason this is a separate module rather than a
11
+ // appendFileSync in five hooks:
12
+ //
13
+ // - An event carries FACTS, never content. Command text, file contents, prompts
14
+ // and tool output can hold secrets; the transcript already has them for anyone
15
+ // who needs them. `makeEvent` keeps an allowlist and types every value, so a
16
+ // caller that passes the whole hook payload still records only facts.
17
+ // - Recording never breaks a hook. Every failure comes back as `{ok:false, why}`.
18
+ // - The reader reports which of three things it found. No file is `none` — not an
19
+ // idle agent. A file it cannot read is `unreadable` — not zero events.
20
+ //
21
+ // Local only: the file stays in the project and nothing is sent anywhere.
22
+ // GREAT_CTO_DISABLE_EVENTS=1 turns recording off.
23
+ //
24
+ // CLI (for hooks written in shell):
25
+ // node scripts/lib/agent-events.mjs --emit <kind> [--tool <name>] [--agent <name>]
26
+
27
+ import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ export const EVENTS_FILE = 'events.jsonl';
31
+ export const EVENT_KINDS = Object.freeze(['agent-start', 'agent-stop', 'tool', 'denied', 'stop', 'pipeline']);
32
+ export const EVENT_FIELDS = Object.freeze(['v', 'ts', 'kind', 'session', 'agent', 'tool', 'paths', 'ok', 'duration_ms', 'outcome', 'verdict']);
33
+ export const MAX_BYTES = 5 * 1024 * 1024;
34
+
35
+ const MAX_PATHS = 10;
36
+ const short = (v, n) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, n) : undefined);
37
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
38
+
39
+ /**
40
+ * One event, reduced to the allowed fields. Null for a kind that is not an event.
41
+ * Values that are not the expected type are dropped, not coerced: a string "yes"
42
+ * is not `ok: true`.
43
+ */
44
+ export function makeEvent(input, { now = Date.now() } = {}) {
45
+ if (!input || !EVENT_KINDS.includes(input.kind)) return null;
46
+ const e = { v: 1, ts: iso(now), kind: input.kind };
47
+ const session = short(input.session, 80); if (session) e.session = session;
48
+ const agent = short(input.agent, 80); if (agent) e.agent = agent;
49
+ const tool = short(input.tool, 80); if (tool) e.tool = tool;
50
+ if (Array.isArray(input.paths)) {
51
+ const paths = input.paths.filter((p) => typeof p === 'string' && p.trim()).map((p) => p.slice(0, 300)).slice(0, MAX_PATHS);
52
+ if (paths.length) e.paths = paths;
53
+ }
54
+ if (typeof input.ok === 'boolean') e.ok = input.ok;
55
+ if (Number.isFinite(input.duration_ms) && input.duration_ms >= 0) e.duration_ms = Math.round(input.duration_ms);
56
+ if (typeof input.outcome === 'string' && /^[a-z][a-z-]{0,39}$/.test(input.outcome)) e.outcome = input.outcome;
57
+ if (typeof input.verdict === 'string' && /^[A-Z][A-Z_-]{0,39}$/.test(input.verdict)) e.verdict = input.verdict;
58
+ return e;
59
+ }
60
+
61
+ /**
62
+ * Append one event to <dir>/events.jsonl. Never throws.
63
+ *
64
+ * @param {string} dir the project's .great_cto directory
65
+ * @returns {{ok:true} | {ok:false, why:string}}
66
+ */
67
+ export function appendEvent(dir, input, { now = Date.now(), env = process.env, maxBytes = MAX_BYTES } = {}) {
68
+ try {
69
+ if (env.GREAT_CTO_DISABLE_EVENTS === '1') return { ok: false, why: 'disabled by GREAT_CTO_DISABLE_EVENTS=1' };
70
+ const e = makeEvent(input, { now });
71
+ if (!e) return { ok: false, why: `not an event kind: ${JSON.stringify(input?.kind)}` };
72
+ const line = JSON.stringify(e) + '\n';
73
+ mkdirSync(dir, { recursive: true });
74
+ const file = join(dir, EVENTS_FILE);
75
+ let size = 0;
76
+ try { size = statSync(file).size; } catch { /* no file yet */ }
77
+ // One previous generation: enough for the board to replay a restart, small
78
+ // enough that a busy project cannot fill a disk with its own activity log.
79
+ // Bytes, not characters: a path in Cyrillic is two bytes a letter, and the
80
+ // board's replay cursor is a byte offset into this file.
81
+ if (size > 0 && size + Buffer.byteLength(line) > maxBytes) renameSync(file, join(dir, 'events.1.jsonl'));
82
+ appendFileSync(file, line);
83
+ return { ok: true };
84
+ } catch (err) {
85
+ return { ok: false, why: String(err?.code || err?.message || err) };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The newest events, oldest first.
91
+ *
92
+ * @returns {{state:'live'|'none'|'unreadable', events:object[], bad?:number, why?:string}}
93
+ */
94
+ export function readEvents(dir, { limit = 50 } = {}) {
95
+ let text;
96
+ try {
97
+ text = readFileSync(join(dir, EVENTS_FILE), 'utf8');
98
+ } catch (err) {
99
+ if (err?.code === 'ENOENT') return { state: 'none', events: [] };
100
+ return { state: 'unreadable', events: [], why: String(err?.code || err?.message || err) };
101
+ }
102
+ const { events, bad } = parseLines(text);
103
+ return { state: 'live', events: events.slice(-Math.max(1, limit)), bad };
104
+ }
105
+
106
+ /** Events and the count of lines that were not one. Shared, so both readers agree. */
107
+ function parseLines(text) {
108
+ const events = [];
109
+ let bad = 0;
110
+ for (const line of text.split('\n')) {
111
+ if (!line.trim()) continue;
112
+ let rec;
113
+ try { rec = JSON.parse(line); } catch { bad++; continue; }
114
+ if (rec && EVENT_KINDS.includes(rec.kind)) events.push(rec); else bad++;
115
+ }
116
+ return { events, bad };
117
+ }
118
+
119
+ // ── resuming (ADR-021 phase 2, great_cto-c0hb) ─────────────────────────────
120
+
121
+ /** The most events one replay sends before it gives up and sends a snapshot. */
122
+ export const MAX_DELTA = 500;
123
+
124
+ function parseCursor(cursor) {
125
+ const m = /^(\d+)-(\d+)$/.exec(String(cursor ?? ''));
126
+ return m ? { ino: Number(m[1]), offset: Number(m[2]) } : null;
127
+ }
128
+
129
+ /**
130
+ * Events after a cursor, or a snapshot when the cursor no longer points into this file.
131
+ *
132
+ * The cursor is `<inode>-<byte offset>`: the offset just past the last complete line
133
+ * consumed. A snapshot — never a read from the wrong place — when there is no cursor,
134
+ * it is not one of ours, the inode differs (the file rotated or was replaced), the
135
+ * file is shorter than the offset (truncated), the offset does not sit just after a
136
+ * newline (truncated and grown back past it), or the gap holds more than maxDelta
137
+ * events (then `gap: true`, so the board can say events were skipped).
138
+ *
139
+ * Not caught: a file truncated and regrown so that a newline lands exactly on the old
140
+ * offset. Same inode, a plausible boundary — indistinguishable from a real resume.
141
+ * Nothing in the plugin truncates this file; rotation renames it.
142
+ *
143
+ * @returns {{state:'live'|'none'|'unreadable', mode?:'delta'|'snapshot', events:object[],
144
+ * cursor:string|null, bad?:number, gap?:boolean, why?:string}}
145
+ */
146
+ export function readEventsSince(dir, cursor, { limit = 50, maxDelta = MAX_DELTA } = {}) {
147
+ let buf, ino;
148
+ try {
149
+ const fd = openSync(join(dir, EVENTS_FILE), 'r');
150
+ try {
151
+ const st = fstatSync(fd);
152
+ ino = st.ino;
153
+ buf = Buffer.alloc(st.size);
154
+ let read = 0;
155
+ while (read < st.size) {
156
+ const n = readSync(fd, buf, read, st.size - read, read);
157
+ if (n === 0) break;
158
+ read += n;
159
+ }
160
+ buf = buf.subarray(0, read);
161
+ } finally { closeSync(fd); }
162
+ } catch (err) {
163
+ if (err?.code === 'ENOENT') return { state: 'none', events: [], cursor: null };
164
+ return { state: 'unreadable', events: [], cursor: null, why: String(err?.code || err?.message || err) };
165
+ }
166
+ // Only complete lines: a hook may be mid-append.
167
+ const end = buf.lastIndexOf(0x0a) + 1;
168
+ const next = `${ino}-${end}`;
169
+ const snapshot = (extra = {}) => {
170
+ const { events, bad } = parseLines(buf.subarray(0, end).toString('utf8'));
171
+ return { state: 'live', mode: 'snapshot', events: events.slice(-Math.max(1, limit)), cursor: next, bad, ...extra };
172
+ };
173
+ const c = parseCursor(cursor);
174
+ if (!c || c.ino !== ino || c.offset > end || (c.offset > 0 && buf[c.offset - 1] !== 0x0a)) return snapshot();
175
+ const { events, bad } = parseLines(buf.subarray(c.offset, end).toString('utf8'));
176
+ if (events.length > maxDelta) return snapshot({ gap: true });
177
+ return { state: 'live', mode: 'delta', events, cursor: next, bad };
178
+ }
179
+
180
+ if (import.meta.url === `file://${process.argv[1]}`) {
181
+ try {
182
+ const argv = process.argv.slice(2);
183
+ const arg = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
184
+ if (argv.includes('--emit')) {
185
+ appendEvent(process.env.GREAT_CTO_DIR || '.great_cto', {
186
+ kind: arg('--emit'), tool: arg('--tool'), agent: arg('--agent'), session: arg('--session'),
187
+ });
188
+ }
189
+ } catch { /* a hook must never fail because an event could not be recorded */ }
190
+ process.exit(0);
191
+ }