great-cto 3.69.0 → 3.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/routes.mjs +5 -0
- package/board/packages/board/lib/work.mjs +2 -1
- package/board/packages/board/public/assets/admin-shell.css +77 -0
- package/board/packages/board/public/assets/admin-shell.js +39 -5
- package/board/packages/board/public/assets/work-view.js +228 -16
- package/board/packages/board/public/index.html +46 -2
- package/board/scripts/codex-pipeline.mjs +7 -1
- package/board/scripts/lib/codex-host-state.mjs +2 -0
- package/board/scripts/lib/codex-pipeline.mjs +73 -5
- package/board/scripts/lib/codex-release.mjs +16 -1
- package/board/scripts/lib/controlled-specialists.mjs +7 -1
- package/board/scripts/lib/controller-dispatch-evidence.mjs +6 -0
- package/board/scripts/lib/execution-trace.mjs +146 -0
- package/dist/main.js +7 -1
- package/package.json +1 -1
|
@@ -4139,6 +4139,9 @@ let metrics = {};
|
|
|
4139
4139
|
let shareState = { enabled: false, url: null };
|
|
4140
4140
|
let currentTab = 'work';
|
|
4141
4141
|
window.GctoWork.bind({
|
|
4142
|
+
canvas: true,
|
|
4143
|
+
navigate: canonical => window.GctoAdmin?.navigateWork(canonical),
|
|
4144
|
+
onViewChange: view => window.GctoAdmin?.setWorkView(view),
|
|
4142
4145
|
project: () => new URLSearchParams(location.search).get('project') || '',
|
|
4143
4146
|
visible: () => currentTab === 'work' || currentTab === 'history',
|
|
4144
4147
|
read: url => api(url),
|
|
@@ -7815,6 +7818,7 @@ async function switchProject(slug) {
|
|
|
7815
7818
|
resetProjectUsage();
|
|
7816
7819
|
if (currentTab === 'usage') loadUsage();
|
|
7817
7820
|
if (currentTab === 'skills') loadSkillsInventory();
|
|
7821
|
+
if (window.GctoWork.parseRoute((location.hash || '').replace(/^#\/?/, ''))?.selectedKey) history.replaceState(history.state, '', '#/work');
|
|
7818
7822
|
window.GctoWork.reset();
|
|
7819
7823
|
// Optimistic UI clear
|
|
7820
7824
|
allTasks = [];
|
|
@@ -7882,6 +7886,7 @@ function filterTasks(q) {
|
|
|
7882
7886
|
function onSearch(q) {
|
|
7883
7887
|
window.GctoWork.search(q);
|
|
7884
7888
|
searchQuery = q;
|
|
7889
|
+
if (currentTab === 'work' || currentTab === 'history') return;
|
|
7885
7890
|
renderKanban(filterTasks(q));
|
|
7886
7891
|
document.getElementById('topbar-task-count').textContent =
|
|
7887
7892
|
`${filterTasks(q).length} ${q ? 'MATCH' : 'ISSUES'}`;
|
|
@@ -8025,11 +8030,21 @@ async function init() {
|
|
|
8025
8030
|
|| fromTaskId
|
|
8026
8031
|
|| 'project';
|
|
8027
8032
|
serverDefaultProject = !urlProject && !(allProjects || []).some(p => p.slug === proj);
|
|
8033
|
+
// Registered default projects get an explicit scope before history entries
|
|
8034
|
+
// are created. Keep the unregistered server-default fallback unqualified.
|
|
8035
|
+
if (!urlProject && !serverDefaultProject) {
|
|
8036
|
+
const scopedURL = new URL(location.href); scopedURL.searchParams.set('project', proj);
|
|
8037
|
+
history.replaceState(history.state, '', scopedURL.toString());
|
|
8038
|
+
const workRoute = (location.hash || '#/work').replace(/^#\/?/, '');
|
|
8039
|
+
if (window.GctoWork.parseRoute(workRoute)) window.GctoWork.navigateRoute(workRoute);
|
|
8040
|
+
}
|
|
8028
8041
|
currentProject = proj;
|
|
8029
8042
|
resetProjectUsage();
|
|
8030
8043
|
if (currentTab === 'usage') loadUsage();
|
|
8031
8044
|
if (currentTab === 'skills') loadSkillsInventory();
|
|
8032
|
-
|
|
8045
|
+
// Work already reads the URL's project during bind(). Late bootstrap data
|
|
8046
|
+
// must refresh that same scope, not erase an Execution/Evidence selection.
|
|
8047
|
+
window.GctoWork.refresh();
|
|
8033
8048
|
document.getElementById('project-name').textContent = proj;
|
|
8034
8049
|
document.getElementById('crumb-project').textContent = proj;
|
|
8035
8050
|
document.title = `${proj} · great_cto`;
|
|
@@ -9430,7 +9445,36 @@ function routeFromHash() {
|
|
|
9430
9445
|
}
|
|
9431
9446
|
return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
|
|
9432
9447
|
}
|
|
9433
|
-
|
|
9448
|
+
let routeReplay = 0, lastRouteReplay = '';
|
|
9449
|
+
async function replayBoardRoute() {
|
|
9450
|
+
if (location.href === lastRouteReplay) return;
|
|
9451
|
+
lastRouteReplay = location.href;
|
|
9452
|
+
const ticket = ++routeReplay;
|
|
9453
|
+
let project = new URLSearchParams(location.search).get('project') || '';
|
|
9454
|
+
const expectedHash = location.hash, expectedURL = location.href;
|
|
9455
|
+
if (!project && typeof currentProject !== 'undefined' && currentProject) {
|
|
9456
|
+
// A legacy unqualified history entry means the server default, not the
|
|
9457
|
+
// project whose records happen to be in memory. Clear it before resolving.
|
|
9458
|
+
window.GctoWork.navigateRoute('work');
|
|
9459
|
+
const defaults = await api('/api/work');
|
|
9460
|
+
if (ticket !== routeReplay || location.href !== expectedURL) return;
|
|
9461
|
+
project = defaults?.projectName || '';
|
|
9462
|
+
if (!project || !(allProjects || []).some(item => item.slug === project)) { location.reload(); return; }
|
|
9463
|
+
const scopedURL = new URL(location.href); scopedURL.searchParams.set('project', project);
|
|
9464
|
+
history.replaceState(history.state, '', scopedURL.toString());
|
|
9465
|
+
}
|
|
9466
|
+
if (project && typeof currentProject !== 'undefined' && currentProject && project !== currentProject) {
|
|
9467
|
+
const loading = switchProject(project);
|
|
9468
|
+
history.replaceState(history.state, '', expectedHash || '#/work');
|
|
9469
|
+
await loading;
|
|
9470
|
+
if (ticket !== routeReplay || project !== new URLSearchParams(location.search).get('project')) return;
|
|
9471
|
+
history.replaceState(history.state, '', expectedHash || '#/work');
|
|
9472
|
+
}
|
|
9473
|
+
if (ticket !== routeReplay) return;
|
|
9474
|
+
const r = routeFromHash(); if (r) switchTab(r);
|
|
9475
|
+
}
|
|
9476
|
+
addEventListener('hashchange', replayBoardRoute);
|
|
9477
|
+
addEventListener('popstate', replayBoardRoute);
|
|
9434
9478
|
addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'work') switchTab(r); });
|
|
9435
9479
|
|
|
9436
9480
|
async function refreshLogs() {
|
|
@@ -8,6 +8,7 @@ import { codexRunStore, listCodexRuns, codexHostDoctor } from './lib/codex-host-
|
|
|
8
8
|
import { beginWork, finishWork, acquireProjectLease, readWorkTask, linkWork, observeWorkRun, controlledDecisions } from './lib/work-tasks.mjs';
|
|
9
9
|
import { detectClaude } from './lib/claude-exec.mjs';
|
|
10
10
|
import { dispatchEvidenceSummary } from './lib/controller-dispatch-evidence.mjs';
|
|
11
|
+
import { projectExecutionTrace } from './lib/execution-trace.mjs';
|
|
11
12
|
import { readBenchmarkRegistration, bindBenchmarkTrial } from './lib/adaptive-benchmark-collector.mjs';
|
|
12
13
|
import { readDomainPolicy } from './lib/domain-admission.mjs';
|
|
13
14
|
|
|
@@ -49,7 +50,11 @@ try {
|
|
|
49
50
|
console.log(JSON.stringify(result, null, 2)); process.exit(result.state === 'ready' ? 0 : 2);
|
|
50
51
|
}
|
|
51
52
|
if (command === 'list') {
|
|
52
|
-
|
|
53
|
+
const output = JSON.stringify(listCodexRuns({ root: value('--dir') ? realpathSync(resolve(value('--dir'))) : null, store }), null, 2) + '\n';
|
|
54
|
+
// Piped stdout is asynchronous: exiting before the callback can truncate JSON.
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
process.stdout.write(output, error => error ? reject(error) : resolve());
|
|
57
|
+
});
|
|
53
58
|
process.exit(0);
|
|
54
59
|
}
|
|
55
60
|
let state;
|
|
@@ -163,6 +168,7 @@ try {
|
|
|
163
168
|
}
|
|
164
169
|
console.log(JSON.stringify({ id: state.id, status: state.status, reason: state.reason,
|
|
165
170
|
controllerDispatch: dispatchEvidenceSummary(state),
|
|
171
|
+
executionGraph: projectExecutionTrace(state),
|
|
166
172
|
release: state.release ? { status: state.release.status, token: state.release.token, adapter: state.release.adapter,
|
|
167
173
|
artifactDigest: state.release.artifactDigest, target: state.release.target, path: state.release.path, url: state.release.url,
|
|
168
174
|
activation: state.release.activation, rollback: state.release.rollback } : null,
|
|
@@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
6
6
|
import { detectCodex } from './codex-exec.mjs';
|
|
7
7
|
import { detectClaude } from './claude-exec.mjs';
|
|
8
8
|
import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
|
|
9
|
+
import { projectExecutionTrace } from './execution-trace.mjs';
|
|
9
10
|
|
|
10
11
|
export const codexRunStore = ({ home = homedir(), env = process.env } = {}) =>
|
|
11
12
|
env.GREAT_CTO_CODEX_RUNS_DIR || join(home, '.great_cto', 'codex-runs');
|
|
@@ -21,6 +22,7 @@ export function projectCodexState(state) {
|
|
|
21
22
|
queue: state.queue || [],
|
|
22
23
|
hostRoutes: state.hostRoutes || {},
|
|
23
24
|
controllerDispatch: dispatchEvidenceSummary(state),
|
|
25
|
+
executionGraph: projectExecutionTrace(state),
|
|
24
26
|
wave: state.wave ? { id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
|
|
25
27
|
rolesCompleted: Object.keys(state.results || {}),
|
|
26
28
|
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId }) =>
|
|
@@ -20,6 +20,7 @@ import { scopedReviewInput, scopedReviewCandidate, attestScopedReview, completeS
|
|
|
20
20
|
import { observeControllerCall } from './controller-dispatch-evidence.mjs';
|
|
21
21
|
import { invocationIdentity } from './invocation-identity.mjs';
|
|
22
22
|
import { bindDomainPolicy, domainInvocation } from './domain-admission.mjs';
|
|
23
|
+
import { initializeTrace, recordTrace, traceAttempt, traceKey, traceIncoming, traceSchedule, traceParents, traceGate, traceApproval } from './execution-trace.mjs';
|
|
23
24
|
|
|
24
25
|
export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
25
26
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
@@ -279,6 +280,7 @@ export function newRun({ root, prompt, allowed, entry = 'product-owner', pluginR
|
|
|
279
280
|
if (specialistPolicy) { state.specialistGraph = structuredClone(graph); validateSpecialistPolicy(state, specialistPolicy); }
|
|
280
281
|
state.hostRoutes = validateRoutes(hostRoutes, state.graph);
|
|
281
282
|
if (domainPolicy) state.domainAdmission = bindDomainPolicy(root, domainPolicy);
|
|
283
|
+
initializeTrace(state);
|
|
282
284
|
return state;
|
|
283
285
|
}
|
|
284
286
|
|
|
@@ -303,6 +305,9 @@ function descendants(state, role) {
|
|
|
303
305
|
|
|
304
306
|
function rewind(state, target, feedback) {
|
|
305
307
|
if (!state.graph[target] || target.includes('.') || externalRoles.has(target)) throw Error('invalid repair target');
|
|
308
|
+
const cause = recordTrace(state, 'rework', { role: target, status: 'rework',
|
|
309
|
+
parents: [traceKey(state, `result:${feedback?.attemptId}`), traceKey(state, `attempt:${feedback?.attemptId}`),
|
|
310
|
+
...traceParents(state, [feedback?.role])], relation: 'retry' });
|
|
306
311
|
const used = (state.attempts || []).filter(a => a.role === target).length;
|
|
307
312
|
if (used >= (state.maxAttempts ?? 1)) {
|
|
308
313
|
state.status = 'blocked'; state.reason = `repair attempt limit reached for ${target}`; return;
|
|
@@ -316,13 +321,19 @@ function rewind(state, target, feedback) {
|
|
|
316
321
|
state.invalidations.at(-1).release = state.release;
|
|
317
322
|
state.release = null;
|
|
318
323
|
}
|
|
319
|
-
for (const role of affected)
|
|
324
|
+
for (const role of affected) {
|
|
325
|
+
delete state.results[role];
|
|
326
|
+
// Retain historical nodes, not invalidated scheduling authority. A queued
|
|
327
|
+
// partner may never have dispatched and therefore still have old inputs.
|
|
328
|
+
traceSchedule(state, role, []);
|
|
329
|
+
}
|
|
320
330
|
state.approvals = state.approvals.filter(a => !affected.has(a.role));
|
|
321
331
|
state.released = state.released.filter(role => !affected.has(role));
|
|
322
332
|
// Replace all affected work, including a not-yet-run join partner. It will be
|
|
323
333
|
// scheduled again by the repaired predecessor, never mixed with old evidence.
|
|
324
334
|
state.queue = [target, ...state.queue.filter(role => !affected.has(role))];
|
|
325
335
|
state.pending = null; state.active = null; state.rework = feedback;
|
|
336
|
+
traceSchedule(state, target, [cause]);
|
|
326
337
|
state.status = 'ready'; delete state.reason;
|
|
327
338
|
if (state.specialistPolicy && target === 'senior-dev') {
|
|
328
339
|
state.graph = structuredClone(state.specialistImplementationGraph || state.specialistGraph); delete state.specialistReview;
|
|
@@ -382,6 +393,10 @@ export function recover(state) {
|
|
|
382
393
|
state.waveHistory ??= [];
|
|
383
394
|
state.waveHistory.push({ id: wave.id, roles: [...roles], hosts: { ...wave.hosts },
|
|
384
395
|
status: 'blocked', reason: state.reason, receipt: wave.receipt, recoveredAt: new Date().toISOString() });
|
|
396
|
+
const recovery = recordTrace(state, 'recovery', { status: 'recovered',
|
|
397
|
+
parents: [traceKey(state, `wave:${wave.id}`), ...roles.map(role => traceKey(state, `dispatch:${wave.attemptIds?.[role]}`))],
|
|
398
|
+
relation: 'recovery', input: current });
|
|
399
|
+
for (const role of roles) traceSchedule(state, role, [recovery]);
|
|
385
400
|
state.wave = null; state.status = 'ready'; delete state.reason;
|
|
386
401
|
return;
|
|
387
402
|
}
|
|
@@ -396,11 +411,19 @@ export function recover(state) {
|
|
|
396
411
|
if (!completeReceipt(currentReceipt) || JSON.stringify(currentReceipt) !== JSON.stringify(expected)) throw Error('recovery refused: working tree changed or receipt unavailable');
|
|
397
412
|
if (state.attempts.filter(a => a.role === state.active).length >= (state.maxAttempts ?? 1)) throw Error('recovery attempt limit reached');
|
|
398
413
|
attempt.status = 'recovered'; attempt.recoveredAt = new Date().toISOString();
|
|
414
|
+
const recovery = recordTrace(state, 'recovery', { role: attempt.role, attemptId: attempt.id, status: 'recovered',
|
|
415
|
+
parents: [traceKey(state, `failure:${attempt.id}`) || traceKey(state, `result:${attempt.id}`) || traceKey(state, `attempt:${attempt.id}`)], relation: 'recovery', input: currentReceipt });
|
|
416
|
+
traceSchedule(state, attempt.role, [recovery]);
|
|
399
417
|
state.active = null; state.status = 'ready'; delete state.reason;
|
|
400
418
|
}
|
|
401
419
|
|
|
402
420
|
export function cancel(state) {
|
|
403
421
|
if (state.status === 'done') throw Error('completed run cannot be cancelled');
|
|
422
|
+
recordTrace(state, 'cancel', { status: 'cancelled', parents: [
|
|
423
|
+
state.pending ? traceGate(state) : null,
|
|
424
|
+
...(state.attempts || []).filter(a => a.status === 'running').map(a => traceKey(state, `attempt:${a.id}`)),
|
|
425
|
+
state.wave ? traceKey(state, `wave:${state.wave.id}`) : null,
|
|
426
|
+
], relation: 'cancel' });
|
|
404
427
|
state.cancelledAt = new Date().toISOString(); state.status = 'cancelled';
|
|
405
428
|
state.pending = null;
|
|
406
429
|
if (state.release && state.release.status !== 'verified') { state.release.status = 'cancelled'; delete state.release.token; }
|
|
@@ -466,9 +489,12 @@ export function advance(state) {
|
|
|
466
489
|
state.status = 'blocked'; state.reason = 'graph cannot enforce high-risk gate floor'; return;
|
|
467
490
|
}
|
|
468
491
|
}
|
|
469
|
-
|
|
492
|
+
const skipped = declared.filter(g => !applicable.includes(g));
|
|
493
|
+
for (const g of skipped) {
|
|
470
494
|
if (state.gatePolicy && !state.gatePolicy.skipped.includes(g)) state.gatePolicy.skipped.push(g);
|
|
471
495
|
}
|
|
496
|
+
const skippedNode = skipped.length ? recordTrace(state, 'gate', { key: `skipped:${role}:${result.digest}`, role, status: 'skipped',
|
|
497
|
+
gates: skipped, parents: traceParents(state, [role, ...joined]), relation: 'gate', resultDigest: result.digest }) : null;
|
|
472
498
|
const gates = applicable.filter(gate => !state.approvals.some(a => a.role === role && a.gate === gate && a.result === result.digest));
|
|
473
499
|
if (gates.length) {
|
|
474
500
|
const receipt = treeReceipt(state.root);
|
|
@@ -485,14 +511,18 @@ export function advance(state) {
|
|
|
485
511
|
// Found by walking shared/pipeline.toml end to end; the two-role fixture
|
|
486
512
|
// has no join and could not see it.
|
|
487
513
|
state.pending = { token: randomUUID(), role, gates, result: result.digest, receipt };
|
|
514
|
+
traceGate(state);
|
|
488
515
|
state.status = 'awaiting-gate'; return;
|
|
489
516
|
}
|
|
490
517
|
state.released.push(role);
|
|
518
|
+
const transition = recordTrace(state, 'transition', { key: `transition:${role}:${result.digest}`, role, verdict: result.verdict,
|
|
519
|
+
resultDigest: result.digest, parents: [...traceParents(state, [role, ...joined]), skippedNode], relation: joined.length ? 'join' : 'transition' });
|
|
491
520
|
for (const next of list(rule.next)) {
|
|
492
521
|
if (state.results[next] && state.graph[`${role}.${result.verdict}`]) {
|
|
493
522
|
rewind(state, next, { role, verdict: result.verdict, summary: result.summary }); return;
|
|
494
523
|
}
|
|
495
524
|
if (!state.results[next] && !state.queue.includes(next)) state.queue.push(next);
|
|
525
|
+
if (!state.results[next]) traceSchedule(state, next, [transition], true);
|
|
496
526
|
}
|
|
497
527
|
}
|
|
498
528
|
try { schedulePreparation(state); }
|
|
@@ -527,6 +557,7 @@ function assertPendingGate(state, token) {
|
|
|
527
557
|
|
|
528
558
|
export function approve(state, token) {
|
|
529
559
|
const { role, gates, result } = assertPendingGate(state, token);
|
|
560
|
+
traceApproval(state, state.pending);
|
|
530
561
|
for (const gate of gates) state.approvals.push({ role, gate, result, at: new Date().toISOString() });
|
|
531
562
|
state.pending = null;
|
|
532
563
|
advance(state);
|
|
@@ -538,12 +569,14 @@ export function reject(state, token, reason) {
|
|
|
538
569
|
const { role } = assertPendingGate(state, token);
|
|
539
570
|
if (!state.graph[role] || externalRoles.has(role) || role.includes('.')) throw Error('gate cannot be reworked by this controller');
|
|
540
571
|
const pending = structuredClone(state.pending);
|
|
572
|
+
const rejected = traceApproval(state, pending, true);
|
|
541
573
|
const modulePath = fileURLToPath(import.meta.url);
|
|
542
574
|
const feedback = { kind: 'operator-rejection', role, reason: reason.trim(), pending,
|
|
543
575
|
controller: { modulePath, sha256: hash(readFileSync(modulePath)) }, at: new Date().toISOString() };
|
|
544
576
|
state.rejections ??= [];
|
|
545
577
|
state.rejections.push(feedback);
|
|
546
578
|
rewind(state, role, feedback);
|
|
579
|
+
traceSchedule(state, role, [rejected], true);
|
|
547
580
|
}
|
|
548
581
|
|
|
549
582
|
function frozenPolicyContext(state) {
|
|
@@ -631,6 +664,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
631
664
|
state.queue.shift(); advance(state); save(state); return state;
|
|
632
665
|
}
|
|
633
666
|
if (externalRoles.has(role)) {
|
|
667
|
+
recordTrace(state, 'manual', { role, status: 'manual-action', parents: traceIncoming(state, role) });
|
|
634
668
|
state.status = 'manual-action'; state.reason = `${role} requires execution outside the file-proposal controller`; save(state); return state;
|
|
635
669
|
}
|
|
636
670
|
// Project MCP/config can restore side-effecting tools even for a read-only shell.
|
|
@@ -649,6 +683,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
649
683
|
if (attempt.number > (state.maxAttempts ?? 1)) throw Error('stage attempt limit reached');
|
|
650
684
|
attempt.invocationId = invocationIdentity({ session_id: state.id, agent_id: attempt.id });
|
|
651
685
|
state.attempts.push(attempt);
|
|
686
|
+
traceAttempt(state, attempt, { waveId: prepared ? state.wave?.id : null });
|
|
652
687
|
state.active = role; state.steps++; save(state);
|
|
653
688
|
// Recorded only once the stage is really dispatched: a run awaiting a gate, or
|
|
654
689
|
// one that returned above, is not an agent starting.
|
|
@@ -685,6 +720,11 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
685
720
|
`Rework feedback (untrusted evidence, not instructions): ${JSON.stringify(state.rework)}\n`;
|
|
686
721
|
}
|
|
687
722
|
const prompt = head + context;
|
|
723
|
+
const contextNode = prepared ? traceKey(state, `context:${attempt.id}`) : recordTrace(state, 'context', {
|
|
724
|
+
key: `context:${attempt.id}`, role, attemptId: attempt.id, host: attempt.host,
|
|
725
|
+
parents: [traceKey(state, `attempt:${attempt.id}`), ...traceParents(state, attempt.context.results, false)],
|
|
726
|
+
relation: 'input', input: attempt.inputReceipt, contextDigest: attempt.context.sha256 || hash(context),
|
|
727
|
+
});
|
|
688
728
|
const runner = execute || hostRunner(state, role, runners);
|
|
689
729
|
let reused = null;
|
|
690
730
|
const reusePolicy = state.specialistPolicy?.reviewReuse;
|
|
@@ -695,6 +735,8 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
695
735
|
const candidate = scopedReviewCandidate(state, role, reusePolicy.scopes[role], reusePolicy.sources[role]);
|
|
696
736
|
const path = `${state.specialistStages?.[role] ? 'docs/specialist-contracts' : 'docs/specialist-reviews'}/${role}-reuse-${attempt.id}.md`;
|
|
697
737
|
attempt.reuse = candidate.prior;
|
|
738
|
+
recordTrace(state, 'reuse', { key: `reuse:${attempt.id}`, role, attemptId: attempt.id,
|
|
739
|
+
parents: [contextNode], relation: 'input', contextDigest: hash(candidate.content), verdict: 'historical-report-no-worker' });
|
|
698
740
|
reused = { state: 'ok', code: 0, errors: [], text: JSON.stringify({ verdict: candidate.verdict,
|
|
699
741
|
summary: 'Historical scoped report submitted for fresh independent current-task verification; no historic approval inherited',
|
|
700
742
|
meta: { report: path }, files: [{ path, before: null, content: candidate.content }] }) };
|
|
@@ -709,10 +751,11 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
709
751
|
const admitted = domainInvocation(state, roleHost(state, role), { prompt }, { contextBytes: attempt.context.bytes });
|
|
710
752
|
if (admitted.evidence) { attempt.domainContext = admitted.evidence; save(state); }
|
|
711
753
|
stageStarted = true; emit(state, { kind: 'agent-start', agent, invocation_id: attempt.invocationId });
|
|
712
|
-
return observeControllerCall(state, { id: `${attempt.id}:worker`, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root, sandbox: 'read-only', ephemeral: true,
|
|
754
|
+
return observeControllerCall(state, { id: `${attempt.id}:worker`, attemptId: attempt.id, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root, sandbox: 'read-only', ephemeral: true,
|
|
713
755
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
714
756
|
invocationId: attempt.invocationId, timeoutMs: 300000, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, attempt.invocationId) }), save);
|
|
715
757
|
});
|
|
758
|
+
const proposalSource = traceKey(state, `dispatch:${attempt.id}`) || traceKey(state, `reuse:${attempt.id}`) || contextNode;
|
|
716
759
|
// Codex can recover its session-index lookup without degrading the worker.
|
|
717
760
|
// Keep the diagnostic in the receipt; every other warning/error blocks.
|
|
718
761
|
const proposal = cleanResponse(response);
|
|
@@ -724,6 +767,8 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
724
767
|
const rule = state.graph[`${role}.${proposal.verdict}`] || state.graph[role];
|
|
725
768
|
if (['FAIL', 'REJECTED'].includes(proposal.verdict) && repairTarget(state, role) !== role) {
|
|
726
769
|
Object.assign(attempt, { status: 'rework', verdict: proposal.verdict, summary: proposal.summary, finishedAt: new Date().toISOString() });
|
|
770
|
+
recordTrace(state, 'result', { key: `result:${attempt.id}`, role, attemptId: attempt.id, host: attempt.host,
|
|
771
|
+
status: 'rework', parents: [proposalSource], relation: 'outcome', verdict: proposal.verdict });
|
|
727
772
|
// A negative reviewer verdict cannot write or approve anything. It can
|
|
728
773
|
// only request bounded repair through the controller-owned route.
|
|
729
774
|
rewind(state, repairTarget(state, role), { role, attemptId: attempt.id, verdict: proposal.verdict, summary: proposal.summary });
|
|
@@ -780,15 +825,23 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
780
825
|
attempt.phase = 'checking'; save(state);
|
|
781
826
|
attempt.checks = await checks(state, { safePath });
|
|
782
827
|
if (!['passed', 'failed', 'unverifiable'].includes(attempt.checks?.state)) throw Error('invalid checks evidence');
|
|
828
|
+
recordTrace(state, 'checks', { key: `checks:${attempt.id}`, role, attemptId: attempt.id, status: attempt.checks.state,
|
|
829
|
+
parents: [proposalSource], relation: 'checks', input: receipt });
|
|
783
830
|
save(state);
|
|
784
831
|
}
|
|
785
832
|
attempt.phase = 'verifying'; save(state);
|
|
786
833
|
const beforeVerifier = stageReceipt(state, treeReceipt(state.root), 'verifier input');
|
|
787
834
|
if (JSON.stringify(beforeVerifier) !== JSON.stringify(receipt)) throw Error('working tree changed before verification');
|
|
835
|
+
if (!attempt.checks || attempt.checks.state === 'passed') {
|
|
836
|
+
recordTrace(state, 'verifier', { key: `verifier:${attempt.id}`, role, attemptId: attempt.id, host: 'codex', status: 'intent',
|
|
837
|
+
parents: [traceKey(state, `checks:${attempt.id}`) || proposalSource,
|
|
838
|
+
...traceParents(state, Object.keys(state.results), false)], relation: 'verification', input: receipt });
|
|
839
|
+
save(state);
|
|
840
|
+
}
|
|
788
841
|
let verification = attempt.checks && attempt.checks.state !== 'passed'
|
|
789
842
|
? { state: attempt.checks.state === 'failed' ? 'rework' : 'unverifiable', findings: [`Required checks ${attempt.checks.state}: ${JSON.stringify(checkSummary(attempt.checks))}`], checks: ['controller executed mandatory checks'] }
|
|
790
843
|
: await withAgentBudget(state, { callId: `${attempt.id}:verifier`, host: 'codex', role: 'codex-verifier' }, () =>
|
|
791
|
-
observeControllerCall(state, { id: `${attempt.id}:verifier`, host: 'codex', role: 'codex-verifier', kind: 'verifier' },
|
|
844
|
+
observeControllerCall(state, { id: `${attempt.id}:verifier`, attemptId: attempt.id, host: 'codex', role: 'codex-verifier', kind: 'verifier' },
|
|
792
845
|
() => verify(state, role, proposal, execute || runCodexExec), save));
|
|
793
846
|
if (!['verified', 'rework', 'unverifiable'].includes(verification?.state) || !Array.isArray(verification.findings) ||
|
|
794
847
|
!Array.isArray(verification.checks) || !verification.checks.length) throw Error('invalid or empty verifier evidence');
|
|
@@ -799,6 +852,9 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
799
852
|
}
|
|
800
853
|
state.verification = verification;
|
|
801
854
|
Object.assign(attempt, { verification, receipt, proposalDigest: hash(JSON.stringify(proposal)), finishedAt: new Date().toISOString() });
|
|
855
|
+
recordTrace(state, 'result', { key: `result:${attempt.id}`, role, attemptId: attempt.id, host: attempt.host, status: verification.state,
|
|
856
|
+
parents: [traceKey(state, `verifier-call:${attempt.id}`) || traceKey(state, `checks:${attempt.id}`)],
|
|
857
|
+
relation: 'outcome', input: attempt.inputReceipt, output: receipt, resultDigest: hash(JSON.stringify({ attemptId: attempt.id, proposal })), verdict: proposal.verdict });
|
|
802
858
|
if (verification.state === 'rework') {
|
|
803
859
|
attempt.status = 'rework';
|
|
804
860
|
const feedback = { role, attemptId: attempt.id, findings: verification.findings, checks: verification.checks };
|
|
@@ -808,6 +864,8 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
808
864
|
if (target !== role) {
|
|
809
865
|
rewind(state, target, feedback);
|
|
810
866
|
} else if (attempt.number < (state.maxAttempts ?? 1)) {
|
|
867
|
+
traceSchedule(state, role, [recordTrace(state, 'rework', { role, attemptId: attempt.id, status: 'rework',
|
|
868
|
+
parents: [traceKey(state, `result:${attempt.id}`)], relation: 'retry' })]);
|
|
811
869
|
state.status = 'ready'; delete state.reason;
|
|
812
870
|
} else {
|
|
813
871
|
state.status = 'blocked'; state.reason = `verifier rework limit reached for ${role}: ${JSON.stringify(verification.findings)}`;
|
|
@@ -838,6 +896,8 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
838
896
|
} catch (error) {
|
|
839
897
|
Object.assign(attempt, { status: 'blocked', reason: error.message, finishedAt: new Date().toISOString() });
|
|
840
898
|
state.status = 'blocked'; state.reason = error.message;
|
|
899
|
+
recordTrace(state, 'result', { key: `failure:${attempt.id}`, role, attemptId: attempt.id, host: attempt.host, status: 'blocked',
|
|
900
|
+
parents: [traceKey(state, `result:${attempt.id}`) || traceKey(state, `verifier-call:${attempt.id}`) || traceKey(state, `verifier:${attempt.id}`) || traceKey(state, `checks:${attempt.id}`) || traceKey(state, `dispatch:${attempt.id}`) || traceKey(state, `reuse:${attempt.id}`) || traceKey(state, `context:${attempt.id}`) || traceKey(state, `attempt:${attempt.id}`)], relation: 'outcome' });
|
|
841
901
|
// Keep active set: a partial write or interrupted process must not be replayed.
|
|
842
902
|
save(state);
|
|
843
903
|
} finally {
|
|
@@ -915,6 +975,14 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
915
975
|
hosts: Object.fromEntries(roles.map(role => [role, roleHost(state, role)])), startedAt: new Date().toISOString() };
|
|
916
976
|
const context = inlineContext(state);
|
|
917
977
|
state.wave.context = context;
|
|
978
|
+
const waveNode = recordTrace(state, 'wave', { key: `wave:${waveId}`, waveId, status: 'intent', input: receipt,
|
|
979
|
+
parents: roles.flatMap(role => traceIncoming(state, role)), relation: 'wave' });
|
|
980
|
+
for (const role of roles) {
|
|
981
|
+
const id = state.wave.attemptIds[role];
|
|
982
|
+
const attemptNode = traceAttempt(state, { id, role, host: roleHost(state, role), inputReceipt: receipt }, { parents: [waveNode], waveId });
|
|
983
|
+
recordTrace(state, 'context', { key: `context:${id}`, role, attemptId: id, host: roleHost(state, role), waveId,
|
|
984
|
+
parents: [attemptNode, ...traceParents(state, context.record.results, false)], relation: 'input', input: receipt, contextDigest: context.record.sha256 });
|
|
985
|
+
}
|
|
918
986
|
try { save(state); } catch (error) { for (const lease of leases) releaseAgent(state.executionBudget, lease); throw error; }
|
|
919
987
|
const calls = roles.map(async (role, index) => {
|
|
920
988
|
const agent = hostAgent(state, role), started = Date.now();
|
|
@@ -927,7 +995,7 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
927
995
|
const admitted = domainInvocation(state, roleHost(state, role), { prompt: workerHead(state, role) + context.text }, { contextBytes: context.record.bytes });
|
|
928
996
|
if (admitted.evidence) { state.wave.domainContexts ??= {}; state.wave.domainContexts[role] = admitted.evidence; save(state); }
|
|
929
997
|
emit(state, { kind: 'agent-start', agent, invocation_id: invocationId }); startedHost = true;
|
|
930
|
-
const result = await observeControllerCall(state, { id: `${waveId}:${role}`, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root,
|
|
998
|
+
const result = await observeControllerCall(state, { id: `${waveId}:${role}`, attemptId: state.wave.attemptIds[role], host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root,
|
|
931
999
|
sandbox: 'read-only', ephemeral: true, timeoutMs: 300000,
|
|
932
1000
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
933
1001
|
invocationId, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, invocationId) }), save);
|
|
@@ -6,6 +6,7 @@ import { validateArtifacts, bundleDigest, digest, artifactPath } from './codex-a
|
|
|
6
6
|
import { validateCheckPolicy, runChecks } from './codex-checks.mjs';
|
|
7
7
|
import { treeReceipt } from './receipt.mjs';
|
|
8
8
|
import { publishGitHubRelease } from './codex-github-release.mjs';
|
|
9
|
+
import { recordTrace, traceKey, traceIncoming, traceSchedule } from './execution-trace.mjs';
|
|
9
10
|
const ROOT_MARKER = '.great-cto-release-root';
|
|
10
11
|
const ROOT_MARKER_CONTENT = 'great-cto-release-root:v1\n';
|
|
11
12
|
const backendFields = policy => policy.backend === 'local'
|
|
@@ -83,11 +84,16 @@ export function prepareRelease(state) {
|
|
|
83
84
|
const r = state.release;
|
|
84
85
|
r.bindingDigest = digest(JSON.stringify({ id: r.id, target: r.target, policyDigest: r.policyDigest, artifactDigest: r.artifactDigest, receipt: r.receipt }));
|
|
85
86
|
state.status = 'awaiting-release';
|
|
87
|
+
recordTrace(state, 'gate', { key: `release:${r.id}:gate`, role: 'devops', status: 'pending',
|
|
88
|
+
parents: traceIncoming(state, 'devops'), relation: 'gate', input: receipt, resultDigest: r.artifactDigest, gates: ['gate:release'] });
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export function approveRelease(state, token) {
|
|
89
92
|
const r = assertRelease(state);
|
|
90
93
|
if (state.status !== 'awaiting-release' || r.status !== 'awaiting-approval' || token !== r.token) throw Error('release approval token does not match');
|
|
94
|
+
const approved = recordTrace(state, 'approval', { key: `release:${r.id}:approval`, role: 'devops', status: 'approved',
|
|
95
|
+
parents: [traceKey(state, `release:${r.id}:gate`)], relation: 'approval', input: r.receipt, resultDigest: r.artifactDigest, gates: ['gate:release'] });
|
|
96
|
+
traceSchedule(state, 'devops', [approved]);
|
|
91
97
|
r.approvedAt = new Date().toISOString(); r.status = 'approved'; delete r.token;
|
|
92
98
|
state.status = 'ready';
|
|
93
99
|
}
|
|
@@ -113,6 +119,9 @@ function verifyDirectory(path, artifacts) {
|
|
|
113
119
|
export function recoverRelease(state) {
|
|
114
120
|
const r = assertRelease(state);
|
|
115
121
|
if (!r.approvedAt || !['publishing', 'failed'].includes(r.status)) throw Error('release is not recoverable');
|
|
122
|
+
const recovery = recordTrace(state, 'recovery', { role: 'devops', status: 'recovered',
|
|
123
|
+
parents: traceIncoming(state, 'devops'), relation: 'recovery', input: r.receipt, resultDigest: r.artifactDigest });
|
|
124
|
+
traceSchedule(state, 'devops', [recovery]);
|
|
116
125
|
// Reuse operation ID and candidate; never rebuild or approve a replacement.
|
|
117
126
|
state.status = 'ready'; delete state.reason;
|
|
118
127
|
}
|
|
@@ -120,6 +129,7 @@ export function recoverRelease(state) {
|
|
|
120
129
|
export async function executeRelease(state, { safePath, checks = runChecks, gh, save = () => {} } = {}) {
|
|
121
130
|
const r = assertRelease(state);
|
|
122
131
|
if (!r.approvedAt || !['approved', 'publishing', 'failed', 'verified'].includes(r.status)) throw Error('release was not approved');
|
|
132
|
+
const publication = recordTrace(state, 'release', { role: 'devops', status: 'intent', parents: traceIncoming(state, 'devops'), relation: 'release', input: r.receipt, resultDigest: r.artifactDigest });
|
|
123
133
|
r.status = 'publishing'; save(state);
|
|
124
134
|
if (r.adapter === 'github-release') {
|
|
125
135
|
try {
|
|
@@ -131,8 +141,11 @@ export async function executeRelease(state, { safePath, checks = runChecks, gh,
|
|
|
131
141
|
}, { safePath }) });
|
|
132
142
|
Object.assign(r, { url: published.url, remoteAssets: published.remoteAssets, smoke: published.smoke,
|
|
133
143
|
publishedAt: r.publishedAt ?? new Date().toISOString(), status: 'verified', verifiedAt: new Date().toISOString() });
|
|
134
|
-
assertRelease(state);
|
|
144
|
+
assertRelease(state);
|
|
145
|
+
recordTrace(state, 'release', { key: `release:${r.id}:published`, role: 'devops', status: 'published', parents: [publication], relation: 'release', resultDigest: r.artifactDigest });
|
|
146
|
+
save(state); return r;
|
|
135
147
|
} catch (error) {
|
|
148
|
+
traceSchedule(state, 'devops', [recordTrace(state, 'release', { role: 'devops', status: 'failed', parents: [publication], relation: 'release', resultDigest: r.artifactDigest })]);
|
|
136
149
|
r.status = 'failed'; state.status = 'blocked'; state.reason = error.message; save(state); throw error;
|
|
137
150
|
}
|
|
138
151
|
}
|
|
@@ -160,8 +173,10 @@ export async function executeRelease(state, { safePath, checks = runChecks, gh,
|
|
|
160
173
|
if (r.smoke?.state !== 'passed' || r.smoke.code !== 0) throw Error('post-release smoke did not pass');
|
|
161
174
|
assertRelease(state);
|
|
162
175
|
r.status = 'verified'; r.verifiedAt = new Date().toISOString();
|
|
176
|
+
recordTrace(state, 'release', { key: `release:${r.id}:published`, role: 'devops', status: 'published', parents: [publication], relation: 'release', resultDigest: r.artifactDigest });
|
|
163
177
|
save(state); return r;
|
|
164
178
|
} catch (error) {
|
|
179
|
+
traceSchedule(state, 'devops', [recordTrace(state, 'release', { role: 'devops', status: 'failed', parents: [publication], relation: 'release', resultDigest: r.artifactDigest })]);
|
|
165
180
|
r.status = 'failed'; state.status = 'blocked'; state.reason = error.message; save(state); throw error;
|
|
166
181
|
} finally {
|
|
167
182
|
// Only this invocation's private staging directory is discarded. A published
|
|
@@ -8,6 +8,7 @@ import { RULES } from '../hooks/auto-attach-reviewers.mjs';
|
|
|
8
8
|
import { codexRoleProfile } from './codex-role-profiles.mjs';
|
|
9
9
|
import { validateReviewReusePolicy } from './scoped-review-reuse.mjs';
|
|
10
10
|
import { assertReviewGraphFloor, assertPreparationGraphFloor } from './review-graph-floor.mjs';
|
|
11
|
+
import { recordTrace, traceIncoming, traceParents, traceSchedule } from './execution-trace.mjs';
|
|
11
12
|
|
|
12
13
|
const mandatory = ['code-reviewer', 'qa-engineer', 'security-officer'];
|
|
13
14
|
const contracts = new Set(['auth-engineer', 'subscription-billing-engineer', 'integrations-engineer', 'connector-builder',
|
|
@@ -82,7 +83,12 @@ export function schedulePreparation(state) {
|
|
|
82
83
|
state.specialistPreparation = { roles, selected, fingerprint: plan.fingerprint, plan, hardGates: gates,
|
|
83
84
|
reports: [], status: roles.length ? 'scheduled' : 'complete', reusablePass: false };
|
|
84
85
|
state.specialistImplementationGraph = structuredClone(state.graph);
|
|
85
|
-
if (roles.length)
|
|
86
|
+
if (roles.length) {
|
|
87
|
+
state.queue.splice(state.queue.indexOf('senior-dev'), 1, ...roles);
|
|
88
|
+
const admission = recordTrace(state, 'transition', { role: 'senior-dev',
|
|
89
|
+
parents: [...traceIncoming(state, 'senior-dev'), ...traceParents(state, Object.keys(state.results))] });
|
|
90
|
+
for (const role of roles) traceSchedule(state, role, [admission]);
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
export function specialistRole(state, role) { return state.specialistStages?.[role]?.role || role; }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Controller invocation evidence, not provider API usage or quality scores. */
|
|
2
|
+
import { recordTrace, traceKey } from './execution-trace.mjs';
|
|
2
3
|
const hosts = new Set(['codex', 'claude-code']);
|
|
3
4
|
const kinds = new Set(['worker', 'verifier']);
|
|
4
5
|
|
|
@@ -23,6 +24,11 @@ export async function observeControllerCall(state, identity, run, save = () => {
|
|
|
23
24
|
throw error;
|
|
24
25
|
} finally {
|
|
25
26
|
record.finishedAt = new Date().toISOString();
|
|
27
|
+
if (identity.attemptId) recordTrace(state, 'result', {
|
|
28
|
+
key: `${identity.kind === 'worker' ? 'dispatch' : 'verifier-call'}:${identity.attemptId}`,
|
|
29
|
+
role: identity.role, attemptId: identity.attemptId, host: identity.host, status: record.outcome,
|
|
30
|
+
parents: [traceKey(state, `${identity.kind === 'worker' ? 'context' : 'verifier'}:${identity.attemptId}`)], relation: 'outcome',
|
|
31
|
+
});
|
|
26
32
|
save(state);
|
|
27
33
|
}
|
|
28
34
|
}
|