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.
- package/board/.claude-plugin/plugin.json +12 -2
- package/board/packages/board/lib/agent-activity.mjs +53 -0
- package/board/packages/board/lib/gate-tokens.mjs +151 -0
- package/board/packages/board/lib/routes.mjs +102 -1
- package/board/packages/board/lib/util.mjs +34 -6
- package/board/packages/board/lib/watchers.mjs +34 -1
- package/board/packages/board/public/index.html +204 -27
- package/board/packages/board/server.mjs +19 -2
- package/board/scripts/codex-pipeline.mjs +80 -0
- package/board/scripts/lib/agent-events.mjs +191 -0
- package/board/scripts/lib/codex-artifacts.mjs +41 -0
- package/board/scripts/lib/codex-checks.mjs +84 -0
- package/board/scripts/lib/codex-exec.mjs +65 -6
- package/board/scripts/lib/codex-github-release.mjs +146 -0
- package/board/scripts/lib/codex-host-state.mjs +93 -0
- package/board/scripts/lib/codex-pipeline.mjs +460 -0
- package/board/scripts/lib/codex-release.mjs +167 -0
- package/board/scripts/lib/codex-role-profiles.mjs +49 -0
- package/board/scripts/lib/pipeline-toml.mjs +21 -0
- package/board/scripts/lib/receipt.mjs +114 -7
- package/board/scripts/lib/secret-patterns.mjs +56 -0
- package/board/scripts/lib/turn-snapshot.mjs +212 -0
- package/board/shared/pipeline.toml +323 -0
- package/dist/codex-host.js +29 -0
- package/dist/codex.js +3 -2
- package/dist/main.js +18 -1
- package/package.json +1 -1
|
@@ -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 ', 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
|
-
|
|
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
|
|
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
|
-
//
|
|
7450
|
-
//
|
|
7451
|
-
//
|
|
7452
|
-
//
|
|
7453
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
7465
|
-
|
|
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
|
+
}
|