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.
@@ -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
- window.GctoWork.reset();
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
- addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
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
- console.log(JSON.stringify(listCodexRuns({ root: value('--dir') ? realpathSync(resolve(value('--dir'))) : null, store }), null, 2));
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) delete state.results[role];
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
- for (const g of declared.filter(g => !applicable.includes(g))) {
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); save(state); return r;
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) state.queue.splice(state.queue.indexOf('senior-dev'), 1, ...roles);
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
  }