great-cto 3.47.0 → 3.49.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/README.md CHANGED
@@ -20,6 +20,34 @@ That's it. The CLI detects your stack, picks the right archetype, clones the plu
20
20
 
21
21
  After install, restart Claude Code and run `/inbox` or `/audit`.
22
22
 
23
+ ## Everyday use (source version)
24
+
25
+ ```bash
26
+ great-cto run "build, fix or investigate something"
27
+ great-cto status
28
+ great-cto resume
29
+ ```
30
+
31
+ Run launches interactive Claude Code through `/start`; resume targets its exact
32
+ pre-bound session through `/resume`. The plugin must be loaded. Status reads
33
+ private task metadata without launching a model. `/start` handles existing projects without resetting
34
+ their configuration. For the controlled Codex runtime use `--host codex` and,
35
+ when starting, an explicit scope such as `--allow src,tests,docs`.
36
+ `status --json` works for both hosts. `--task UUID` selects persisted task identity;
37
+ Codex status also includes its sanitized run listing. Optional repeated `--accept`
38
+ criteria are stored and passed to execution and verification. `--operation UUID`
39
+ provides idempotent launch receipts, and `resume --revision N` rejects stale intent.
40
+ See [the shared task contract](../../docs/WORK-TASK-CONTRACT.md) for ownership and recovery.
41
+ `resume --host codex` selects only one unfinished run in the target project;
42
+ ambiguity requires a UUID. Pending gates, runtime locks and verifier checks remain
43
+ in force. `--dir PATH` targets another project; `--dry-run` previews without agents.
44
+ Advanced policies, recovery and approvals stay under `codex-host`.
45
+ Research uses explicit `run --intent research`; controlled research writes reports
46
+ only and preserves its bounded stage retry budget. Advanced host approvals, outcome
47
+ attestations and cohort reports are under `task work`, documented in the shared
48
+ contract. Resume progress is measured from bound stage evidence, not exit zero.
49
+ Published npm versions may not include these source additions yet.
50
+
23
51
  ## What the plugin gives you
24
52
 
25
53
  The CLI installs it; these are what it installs.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 71 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.47.0",
3
+ "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
+ "version": "3.49.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -81,6 +81,11 @@
81
81
  "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | sed \"s|/$||\")}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/register-project.mjs\" </dev/null 2>/dev/null || true",
82
82
  "timeout": 10,
83
83
  "statusMessage": "Registering project on the board..."
84
+ },
85
+ {
86
+ "type": "command",
87
+ "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | sed \"s|/$||\")}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/learner-report.mjs\" </dev/null 2>/dev/null || true",
88
+ "timeout": 10
84
89
  }
85
90
  ]
86
91
  }
@@ -464,6 +469,7 @@
464
469
  "./agents-full/regulated-reviewer.md",
465
470
  "./agents-full/security-officer.md",
466
471
  "./agents-full/senior-dev.md",
472
+ "./agents-full/smart-contract-auditor.md",
467
473
  "./agents-full/streaming-reviewer.md",
468
474
  "./agents-full/subscription-billing-engineer.md",
469
475
  "./agents-full/tax-reviewer.md",
@@ -40,6 +40,7 @@ import { readVerdicts } from './verdicts.mjs';
40
40
  import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
41
41
  import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
42
42
  import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
43
+ import { getWork } from './work.mjs';
43
44
  import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
44
45
  import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
45
46
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
@@ -99,6 +100,24 @@ async function dispatch(req, res, url, cwd) {
99
100
  }
100
101
  }
101
102
 
103
+ // New read model refuses project fallback rather than relabeling another project.
104
+ if (pathname === '/api/work') {
105
+ if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
106
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
107
+ res.end(JSON.stringify({ error: 'Unknown project; work projection was not read' }));
108
+ return true;
109
+ }
110
+ if (req.method !== 'GET') {
111
+ res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' });
112
+ res.end(JSON.stringify({ error: 'Work execution is not connected; use the host CLI' }));
113
+ return true;
114
+ }
115
+ const snapshot = getWork(cwd);
116
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
117
+ res.end(JSON.stringify(snapshot));
118
+ return true;
119
+ }
120
+
102
121
  // SSE
103
122
  if (pathname === '/api/sse') {
104
123
  res.writeHead(200, {
@@ -0,0 +1,136 @@
1
+ /** Project-scoped read model. Never invent task/run links or dispatch from a GET. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readdirSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { join, basename } from 'node:path';
5
+ import { getTasks, getReadDegradation } from './beads.mjs';
6
+ import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
7
+ import { listWorkTasks, publicWorkTask } from '../../../scripts/lib/work-tasks.mjs';
8
+ import { decisionCapabilities } from '../../../scripts/lib/work-decisions.mjs';
9
+ import { measureWork, compareWork } from '../../../scripts/lib/work-metrics.mjs';
10
+ import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
11
+
12
+ const text = v => typeof v === 'string' ? v : null;
13
+ const date = v => typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null;
14
+ const RUN_PHASE = { ready: 'accepted', 'awaiting-gate': 'needs_decision',
15
+ 'awaiting-release': 'needs_decision', blocked: 'blocked', 'manual-action': 'blocked' };
16
+
17
+ export function projectWork({ projectId, issues = [], codex = { state: 'absent', runs: [] },
18
+ tasks = [], sessions = [], sources = [], observedAt = new Date().toISOString() }) {
19
+ const entries = [];
20
+ for (const r of codex.runs || []) {
21
+ // Completion of a controller run is not proof of the user's acceptance criteria.
22
+ const terminal = ['done', 'cancelled'].includes(r.status);
23
+ const active = !!r.active || r.wave?.status === 'running';
24
+ const phase = active ? 'working' : RUN_PHASE[r.status] || null;
25
+ const pending = !!r.pending || ['awaiting-gate', 'awaiting-release'].includes(r.status);
26
+ const canCopyResume = !terminal && !pending && !active && r.status === 'ready';
27
+ const updatedAt = (r.attempts || []).flatMap(a => [date(a.startedAt), date(a.finishedAt)])
28
+ .filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] || date(r.pending?.createdAt);
29
+ entries.push({ key: `run:${r.id}`, kind: 'run', taskId: null, runId: r.id, issueIds: [],
30
+ title: `Controlled run ${r.id}`, goal: null, acceptance: [], host: 'codex',
31
+ phase, nativeState: text(r.status), terminal, updatedAt: updatedAt || null,
32
+ outcome: r.status === 'done' ? 'Run finished; task acceptance is not recorded'
33
+ : r.status === 'cancelled' ? 'Run cancelled' : null,
34
+ reason: text(r.reason), decisions: pending ? [{ id: `run:${r.id}:pending`, engine: 'codex',
35
+ label: r.status === 'awaiting-release' ? 'Release approval in host controller' : 'Approval in host controller' }] : [],
36
+ capabilities: [{ action: 'copy_resume', enabled: canCopyResume,
37
+ reason: canCopyResume ? null : pending ? 'Resolve the host decision first' : active ? 'A worker owns this run'
38
+ : terminal ? 'This run is terminal' : 'This state does not permit ordinary resume' }],
39
+ command: canCopyResume ? `great-cto resume ${r.id} --host codex` : null,
40
+ evidence: (r.rolesCompleted || []).map(role => ({ kind: 'recorded_role', label: role })),
41
+ release: r.release ? { status: text(r.release.status), url: text(r.release.url), verifiedAt: date(r.release.verifiedAt) } : null,
42
+ });
43
+ }
44
+ for (const t of issues) {
45
+ const terminal = ['closed', 'done'].includes(t.raw_status || t.status);
46
+ const pending = !!t.is_gate && !terminal && t.raw_status !== 'blocked' && t.status !== 'blocked';
47
+ entries.push({ key: `issue:${t.id}`, kind: 'issue', taskId: null, runId: null, issueIds: [t.id],
48
+ title: text(t.title) || t.id, goal: null, acceptance: text(t.acceptance) ? [t.acceptance] : [],
49
+ host: null, phase: null, nativeState: text(t.raw_status || t.status), terminal,
50
+ updatedAt: date(t.updated_at), outcome: terminal ? 'Issue closed; task acceptance is not recorded' : null,
51
+ reason: 'Legacy work item; no execution run is linked',
52
+ decisions: pending ? [{ id: t.id, engine: 'beads', label: 'Review decision' }] : [],
53
+ capabilities: [{ action: pending ? 'review_decision' : 'view_issue', enabled: true, reason: null }],
54
+ command: null, evidence: [], release: null,
55
+ });
56
+ }
57
+ for (const t of tasks) {
58
+ const linkedRuns = entries.filter(e => e.kind === 'run' && t.links.runs.includes(e.runId));
59
+ const linkedIssues = entries.filter(e => e.kind === 'issue' && e.issueIds.some(id => t.links.issues.includes(id)));
60
+ for (const linked of [...linkedRuns, ...linkedIssues]) {
61
+ linked.taskId = t.taskId; linked.goal = t.goal; linked.acceptance = t.acceptance;
62
+ if (!linked.terminal) entries.splice(entries.indexOf(linked), 1);
63
+ }
64
+ const owner = t.operations.some(o => o.state === 'running');
65
+ const run = linkedRuns.length === 1 ? linkedRuns[0] : null;
66
+ const approval = decisionCapabilities(t).find(d => d.capability.enabled);
67
+ const approveCommand = approval ? `great-cto task work approve --task ${t.taskId} --decision ${approval.decisionId} --revision ${t.revision}` : null;
68
+ const enabled = !['cancelled', 'verified', 'completed'].includes(t.phase) && !owner && (t.host === 'codex'
69
+ ? !!run?.capabilities.some(c => c.action === 'copy_resume' && c.enabled)
70
+ : t.managed !== false && t.links.sessions.length === 1 && !['needs_decision', 'working'].includes(t.phase));
71
+ entries.push({ key: `task:${t.taskId}`, kind: 'task', taskId: t.taskId, runId: run?.runId || null,
72
+ issueIds: t.links.issues, title: t.goal, goal: t.goal, acceptance: t.acceptance,
73
+ host: t.host, phase: t.phase, nativeState: run?.nativeState || t.phase,
74
+ terminal: ['cancelled', 'completed'].includes(t.phase), updatedAt: t.updatedAt, outcome: t.outcome?.state === 'completed' ? `${t.intent || 'delivery'} outcome explicitly completed` : null, reason: t.reason,
75
+ intent: t.intent || 'delivery', stage: t.stage || null, taskOutcome: t.outcome || null,
76
+ decisions: decisionCapabilities(t).map(d => ({ ...d, id: d.decisionId, label: d.label || 'Host decision' })), evidence: (t.evidence || []).map(e => ({ kind: 'verdict', label: `${e.role}: ${e.verdict || 'not recorded'}` })),
77
+ release: run?.release || null, revision: t.revision, metrics: t.metrics,
78
+ capabilities: [...(approval ? [{ action: 'copy_approve', enabled: true, reason: null }] : []), { action: 'copy_resume', enabled, reason: enabled ? null : owner ? 'Host operation is active; duplicate resume is refused'
79
+ : t.managed === false ? 'Continue this observed session inside its native host'
80
+ : t.phase === 'needs_decision' ? 'Resolve the native host decision first' : 'Execution link or resumable host state is unavailable' }],
81
+ command: approveCommand || (enabled ? `great-cto resume --task ${t.taskId} --host ${t.host} --revision ${t.revision}` : null),
82
+ });
83
+ }
84
+ entries.sort((a, b) => (a.terminal - b.terminal) || (b.decisions.length - a.decisions.length)
85
+ || (Date.parse(b.updatedAt || '') || 0) - (Date.parse(a.updatedAt || '') || 0) || a.key.localeCompare(b.key));
86
+ const health = sources.some(s => ['degraded', 'unavailable'].includes(s.health)) ? 'degraded' : 'current';
87
+ const measurement = measureWork(tasks);
88
+ const payload = { schemaVersion: 1, projectId, observedAt, health, sources, tasks, entries, measurement, simplification: compareWork(measurement, null),
89
+ sessions: sessions.map(s => ({ session: s.session, state: s.state, since: date(s.since), reason: text(s.reason) })),
90
+ decisions: entries.flatMap(e => e.decisions.map(d => ({ ...d, entryKey: e.key }))),
91
+ execution: { enabled: false, reason: 'This board prepares commands; host execution is not connected' } };
92
+ // A timestamp is not a revision. Stable revision changes only with projected content.
93
+ const { observedAt: ignored, sources: sourceRows, ...content } = payload;
94
+ payload.revision = createHash('sha256').update(JSON.stringify({ ...content,
95
+ sources: sourceRows.map(({ observedAt: ignoredSourceTime, ...s }) => s) })).digest('hex');
96
+ return payload;
97
+ }
98
+
99
+ export function getWork(cwd) {
100
+ const observedAt = new Date().toISOString();
101
+ const sources = []; let issues = [], codex = { state: 'absent', runs: [] }, sessions = [], tasks = [];
102
+ const source = (id, health, reason = null) => sources.push({ id, health, reason, observedAt });
103
+ try {
104
+ const listing = listWorkTasks(cwd); tasks = listing.tasks.map(publicWorkTask);
105
+ source('tasks', listing.state === 'degraded' ? 'degraded' : 'current', listing.unreadable ? `${listing.unreadable} task state file(s) could not be read` : null);
106
+ } catch { source('tasks', 'unavailable', 'Cannot read shared task metadata'); }
107
+ try {
108
+ issues = getTasks(cwd);
109
+ const reason = getReadDegradation(cwd);
110
+ source('issues', reason ? 'degraded' : 'current', reason);
111
+ } catch { source('issues', 'unavailable', 'Cannot read project work items'); }
112
+ try {
113
+ codex = listCodexRuns({ root: cwd });
114
+ source('codex', codex.state === 'degraded' ? 'degraded' : 'current', codex.unreadable
115
+ ? `${codex.unreadable} run state file(s) could not be read; project membership is unknown` : null);
116
+ } catch { source('codex', 'unavailable', 'Cannot read the host run store'); }
117
+ try {
118
+ // Native reader drops malformed files. Check separately so absence stays honest.
119
+ let files = [];
120
+ try { files = readdirSync(join(cwd, '.great_cto', 'status')).filter(f => f.endsWith('.json')); }
121
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
122
+ let unreadable = 0;
123
+ for (const f of files) {
124
+ try {
125
+ const s = JSON.parse(readFileSync(join(cwd, '.great_cto', 'status', f), 'utf8'));
126
+ if (!text(s.session) || !date(s.since) || !['blocked', 'waiting', 'working'].includes(s.state)) unreadable++;
127
+ } catch { unreadable++; }
128
+ }
129
+ sessions = readSessionStatus(cwd);
130
+ source('claude_sessions', unreadable ? 'degraded' : 'current', unreadable ? `${unreadable} session state file(s) could not be read` : null);
131
+ } catch { source('claude_sessions', 'unavailable', 'Cannot read Claude session status'); }
132
+ let canonical = cwd;
133
+ try { canonical = realpathSync(cwd); } catch { /* source health already reports inaccessible data */ }
134
+ const projectId = 'project:' + createHash('sha256').update(canonical).digest('hex');
135
+ return { ...projectWork({ projectId, issues, codex, tasks, sessions, sources, observedAt }), projectName: basename(cwd) };
136
+ }
@@ -0,0 +1,97 @@
1
+ /* Read-side work UI; injected callbacks retain existing board authority. */
2
+ (function () {
3
+ const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
4
+ const quote = value => "'" + String(value).replace(/'/g, "'\\''") + "'";
5
+ function commandFor(goal, host, allow) {
6
+ if (!goal.trim()) throw Error('Describe the result first.');
7
+ if (!['claude-code', 'codex'].includes(host)) throw Error('Choose a supported host.');
8
+ if (host === 'codex' && (!allow.trim() || allow.split(',').some(p => !p.trim()))) throw Error('Codex requires an explicit comma-separated write scope.');
9
+ return `great-cto run --host ${host}` + (host === 'codex' ? ` --allow ${quote(allow.trim())}` : '') + ` -- ${quote(goal.trim())}`;
10
+ }
11
+ const phaseLabel = e => e.outcome || ({ waiting: 'Waiting; acceptance is not confirmed', unknown: 'Execution state is unknown', verified: 'Verified outcome; explicit completion pending', completed: 'Requested outcome explicitly completed', publishing: 'Host publication in progress', cancelled: 'Cancelled', working: 'Host activity recorded; liveness is not measured', accepted: 'Ready for host execution', needs_decision: 'Needs a decision', blocked: 'Blocked' }[e.phase])
12
+ || (e.kind === 'issue' ? `Issue: ${e.nativeState || 'unknown'}` : `Run: ${e.nativeState || 'unknown'}`);
13
+ function render(snapshot, history = false, filter = '') {
14
+ const warnings = snapshot.sources.filter(s => ['degraded', 'unavailable'].includes(s.health));
15
+ const rows = snapshot.entries.filter(e => e.terminal === history && (!filter || `${e.title} ${e.key}`.toLowerCase().includes(filter.toLowerCase())));
16
+ return `<p class="work-meta">Observed ${esc(snapshot.observedAt)} · ${snapshot.health === 'current' ? 'Sources read' : 'Sources incomplete'}</p>`
17
+ + warnings.map(s => `<p class="work-warning">${esc(s.id)}: ${esc(s.reason || s.health)}</p>`).join('')
18
+ + (rows.length ? rows.map(e => {
19
+ const cap = e.capabilities.find(c => c.enabled);
20
+ const action = cap ? `<button type="button" class="gate-btn" data-work-key="${esc(e.key)}" data-work-action="${esc(cap.action)}">${cap.action === 'copy_approve' ? 'Copy approval command' : cap.action === 'copy_resume' ? 'Copy resume command' : cap.action === 'review_decision' ? 'Review decision' : 'Open issue'}</button>` : '';
21
+ return `<article class="work-card" data-entry-key="${esc(e.key)}"><h2>${esc(e.title)}</h2><p>${esc(phaseLabel(e))}</p>`
22
+ + `<p class="work-meta">${esc(e.host || 'Host not linked')} · ${e.updatedAt ? esc(e.updatedAt) : 'Update time not recorded'}</p>`
23
+ + (e.acceptance.length ? `<p>Acceptance: ${esc(e.acceptance.join('; '))}</p>` : '')
24
+ + `<p>${esc(e.reason || 'Execution observation is recorded; acceptance is not inferred.')}</p>`
25
+ + e.decisions.map(d => `<p>${esc(d.label)}${d.engine === 'codex' ? ' · resolve in the host controller' : ''}</p>`).join('')
26
+ + action + (e.command ? `<pre>${esc(e.command)}</pre>` : '')
27
+ + (!cap && e.capabilities[0]?.reason ? `<p>${esc(e.capabilities[0].reason)}</p>` : '')
28
+ + `<details><summary>Technical details</summary><p>${esc(e.key)} · ${esc(e.nativeState || 'unknown')}</p>`
29
+ + `<p>Task linkage: ${esc(e.taskId || 'not recorded')}</p>${e.revision ? `<p>Task revision: ${esc(e.revision)}</p>` : ''}`
30
+ + (e.taskOutcome ? `<p>Outcome evidence: ${esc(e.taskOutcome.source || 'operator-attestation')} · ${esc(e.taskOutcome.state)}</p>` : '')
31
+ + `<p>Recorded roles: ${esc(e.evidence.map(v => v.label).join(', ') || 'none recorded; verification is not inferred')}</p>`
32
+ + (e.release ? `<p>Release: ${esc(e.release.status || 'unknown')} · verified ${esc(e.release.verifiedAt || 'not recorded')}</p>` + (typeof e.release.url === 'string' && /^https?:\/\//i.test(e.release.url) ? `<p><a href="${esc(e.release.url)}" target="_blank" rel="noopener noreferrer">Open release result</a></p>` : '') : '')
33
+ + `</details></article>`;
34
+ }).join('') : `<p>${warnings.length ? 'No readable entries match this view; sources are incomplete.' : filter ? 'No entries match the search.' : history ? 'No closed work records or terminal runs recorded.' : 'No active work records or runs recorded. Describe a result to prepare a host command.'}</p>`)
35
+ + (!history && snapshot.sessions.length ? `<details><summary>Claude sessions (${snapshot.sessions.length}) · task linkage not recorded</summary>`
36
+ + snapshot.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('') + '</details>' : '');
37
+ }
38
+ let config, snapshot, generation = 0, filter = '';
39
+ const status = message => { document.getElementById('work-feedback').textContent = message; };
40
+ async function refresh() {
41
+ const seq = ++generation, project = config.project();
42
+ try {
43
+ const data = await config.read(`/api/work${project ? '?project=' + encodeURIComponent(project) : ''}`);
44
+ if (seq !== generation || project !== config.project()) return;
45
+ if (!data || data.error || data.schemaVersion !== 1) throw Error(data?.error || 'Cannot read work state. Check the board connection and refresh.');
46
+ if (snapshot?.revision === data.revision) return;
47
+ snapshot = data; paint();
48
+ } catch (error) {
49
+ if (seq !== generation || project !== config.project()) return;
50
+ snapshot = null;
51
+ for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = error.message;
52
+ }
53
+ }
54
+ function paint() {
55
+ if (!snapshot) return;
56
+ const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => d.closest('[data-entry-key]').dataset.entryKey);
57
+ const focused = document.activeElement;
58
+ const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
59
+ document.getElementById('work-list').innerHTML = render(snapshot, false, filter);
60
+ document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
61
+ for (const card of document.querySelectorAll('[data-entry-key]')) if (expanded.includes(card.dataset.entryKey)) card.querySelector('details').open = true;
62
+ if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
63
+ }
64
+ function reset() {
65
+ generation++; snapshot = null; status('');
66
+ for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = 'Loading project work…';
67
+ refresh();
68
+ }
69
+ async function copy(command) {
70
+ try { await navigator.clipboard.writeText(command); status('Command copied. Run it in a terminal in the selected project; execution has not started here.'); }
71
+ catch { status('Clipboard unavailable. Select and copy the displayed command.'); }
72
+ }
73
+ function bind(options) {
74
+ config = options;
75
+ document.getElementById('work-compose').addEventListener('submit', event => {
76
+ event.preventDefault();
77
+ try {
78
+ const cmd = commandFor(document.getElementById('work-goal').value, document.getElementById('work-host').value, document.getElementById('work-allow').value);
79
+ document.getElementById('work-command').textContent = cmd; copy(cmd);
80
+ } catch (error) { document.getElementById('work-command').textContent = ''; status(error.message); }
81
+ });
82
+ document.getElementById('work-host').addEventListener('change', event => {
83
+ document.getElementById('work-allow-row').hidden = event.target.value !== 'codex';
84
+ document.getElementById('work-command').textContent = ''; status('');
85
+ });
86
+ for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
87
+ const button = event.target.closest('[data-work-action]'); if (!button || !snapshot) return;
88
+ const entry = snapshot.entries.find(e => e.key === button.dataset.workKey);
89
+ const action = entry?.capabilities.find(c => c.action === button.dataset.workAction && c.enabled);
90
+ if (!action) return;
91
+ if (action.action === 'copy_resume' || action.action === 'copy_approve') copy(entry.command);
92
+ else options.openIssue(entry.issueIds[0], action.action === 'review_decision');
93
+ });
94
+ setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
95
+ }
96
+ window.GctoWork = { bind, refresh, reset, render, commandFor, search(value) { filter = value; paint(); } };
97
+ })();
@@ -108,6 +108,7 @@
108
108
  --status-review-text: #00d97e; /* bright on dark: the bed and the ink can be one colour */
109
109
  --status-done: #00d97e;
110
110
  --status-blocked: #ff5466;
111
+ --status-blocked-ink: #ff5466; /* blocked-status TEXT on a tinted surface */
111
112
  --status-gate: #a78bfa;
112
113
 
113
114
  --p0-bg: rgba(255, 84, 102, 0.14); --p0-fg: #fca5a5;
@@ -198,6 +199,7 @@
198
199
  --status-review-text: #097448;
199
200
  --status-done: #0a7d4d;
200
201
  --status-blocked: #c43030;
202
+ --status-blocked-ink: #a32626; /* #c43030 reads 4.08:1 on the red-tinted banner; this is 5.45:1 */
201
203
  --status-gate: #7c3aed;
202
204
  --p0-bg: rgba(196, 48, 48, 0.12); --p0-fg: #b91c1c;
203
205
  --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
@@ -378,7 +380,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
378
380
 
379
381
  /* The budgets panel. A cap with no consumption beside it cannot answer the only
380
382
  question worth asking of it, so every row carries the state and the spend. */
381
- .ab-title { font-weight: 600; margin-bottom: 8px; }
383
+ .ab-title { font-weight: 500; margin-bottom: 8px; }
382
384
  .ab-row {
383
385
  display: flex; align-items: center; gap: 10px;
384
386
  padding: 4px 0; border-top: 1px solid var(--border);
@@ -886,6 +888,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
886
888
  color: var(--text3);
887
889
  text-align: center;
888
890
  }
891
+ /* A <small> hint inside an empty state rendered at the browser's "smaller"
892
+ (13px × 5/6 = 10.83px), off the type scale; the layout gate caught it whenever
893
+ a fresh board had no logs. Pin it to the caption size. */
894
+ .empty small { font-size: var(--fs-caption); }
889
895
 
890
896
  /* A read failed. Deliberately louder than .empty — the whole point is that this
891
897
  must not be mistaken for "there is nothing here". */
@@ -917,7 +923,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
917
923
  border-color: var(--status-blocked);
918
924
  background: var(--rail-fail-bg);
919
925
  }
920
- .project-fallback-banner strong { color: var(--status-blocked); }
926
+ .project-fallback-banner strong { color: var(--status-blocked-ink); }
921
927
  .project-fallback-banner span { color: var(--text); font-family: var(--sans); }
922
928
 
923
929
  /* ── Side panel ───────────────────────────────────────────────────────────── */
@@ -2565,7 +2571,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2565
2571
  /* ── Agents installed list (legacy — kept for fallback) ───────────────── */
2566
2572
  .agents-installed { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin-top: 4px; }
2567
2573
  /* Agent budget bars (Paperclip cascading budget pattern) */
2568
- .ab-title { font-weight: 600; color: var(--text2); margin-bottom: 6px; }
2574
+ .ab-title { font-weight: 500; color: var(--text2); margin-bottom: 6px; }
2569
2575
  .ab-row { display: flex; justify-content: space-between; padding: 2px 0; }
2570
2576
  .ab-name { font-family: var(--mono, monospace); color: var(--accent-text); }
2571
2577
  .ab-cap { color: var(--text3); }
@@ -3261,9 +3267,27 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3261
3267
  ::-webkit-scrollbar { width: 10px; height: 10px; }
3262
3268
  ::-webkit-scrollbar-thumb { background: rgba(15, 17, 21, 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
3263
3269
  ::-webkit-scrollbar-thumb:hover { background: rgba(15, 17, 21, 0.22); border: 2px solid transparent; background-clip: content-box; }
3270
+ /* Task read-side: reuse the board's measured typography and colour tokens. */
3271
+ .nav-views[hidden], #nav-views-label[hidden], #work-allow-row[hidden] { display: none; }
3272
+ .work-page { padding: 24px; max-width: 1000px; }
3273
+ .work-card, #work-compose { padding: 16px; border: 1px solid var(--border); border-radius: 8px; margin: 12px 0; background: var(--bg-card); }
3274
+ .work-page h1 { font-size: var(--fs-title-l); font-weight: 500; }
3275
+ .work-page .gate-btn { padding: 8px 12px; min-height: 36px; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg-solid); color: var(--on-solid); cursor: pointer; }
3276
+ .work-page .gate-btn:hover { background: var(--bg-solid-hover); }
3277
+ .work-card a { color: var(--text); text-decoration: underline; }
3278
+ .work-card h2 { font-size: var(--fs-title-s); }
3279
+ .work-card p, #work-compose p { margin: 8px 0; font-size: var(--fs-body); }
3280
+ .work-meta { color: var(--text2); font-size: var(--fs-caption); }
3281
+ .work-warning { color: var(--absent-warn-fg); background: var(--bg-card); border: 1px solid var(--absent-warn-fg); padding: 12px; }
3282
+ .work-page pre { font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; margin: 12px 0; }
3283
+ #work-compose textarea, #work-compose input, #work-compose select { display: block; width: 100%; margin: 8px 0 12px; padding: 8px; background: var(--bg-muted); color: var(--text); border: 1px solid var(--border-strong); border-radius: 4px; }
3284
+ #tools-nav summary { padding: 12px 16px; cursor: pointer; }
3285
+ @media (min-width: 768px) and (max-width: 1199px) { .nav-item[data-tab="work"] > span, .nav-item[data-tab="history"] > span { display: inline; font-size: var(--fs-caption); } #tools-nav summary { padding: 12px 4px; } }
3286
+ @media (max-width: 767px) { .work-page { padding: 12px; } .work-page button, #work-compose select { min-height: 44px; } }
3264
3287
  </style>
3265
3288
  </head>
3266
3289
  <body>
3290
+ <script src="/assets/work-view.js"></script>
3267
3291
  <div class="board">
3268
3292
  <!-- Subtle emerald glow background -->
3269
3293
  <div class="board-bg" aria-hidden="true"></div>
@@ -3299,21 +3323,17 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3299
3323
  <div id="proj-menu-list"></div>
3300
3324
  </div>
3301
3325
  </div>
3302
- <!-- Nav shell cutover (great_cto-ki1x.5): eight tabs (+ a topbar share
3303
- button and bell) become four destinations. Tasks/kanban, Metrics/
3304
- dashboard, Docs, Activity/logs, Notifications and Share lose their
3305
- entry point here — their panels and functions are untouched, so nothing
3306
- that already linked to `#dashboard`/`switchTab('budgets', …)`/etc. goes
3307
- dead; a bookmark just stops appearing in the menu. Settings — the
3308
- future home for the Share/Notifications rows this detaches — is
3309
- reachable from the topbar gear, not a fifth item here (kept under the
3310
- ≤5 ceiling `ui-ux-pro-max` sets for the same nav to also serve mobile). -->
3311
3326
  <nav class="nav" id="nav-tablist" role="tablist" aria-orientation="vertical">
3312
- <div class="nav-item active" data-tab="decisions" role="tab" tabindex="0" aria-label="Decisions" title="Decisions" onkeydown="navTabKeydown(event, this)" onclick="switchTab('decisions', this)">
3327
+ <div class="nav-item active" data-tab="work" role="tab" tabindex="0" aria-label="Work" title="Work" onkeydown="navTabKeydown(event, this)" onclick="switchTab('work', this)"><span>Work</span></div>
3328
+ <div class="nav-item" data-tab="decisions" role="tab" tabindex="0" aria-label="Decisions" title="Decisions" onkeydown="navTabKeydown(event, this)" onclick="switchTab('decisions', this)">
3313
3329
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-6l-2 3h-4l-2-3H2 M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
3314
3330
  <span>Decisions</span>
3315
3331
  <span class="count" id="nav-inbox-count">0</span>
3316
3332
  </div>
3333
+ <div class="nav-item" data-tab="history" role="tab" tabindex="0" aria-label="History" title="History" onkeydown="navTabKeydown(event, this)" onclick="switchTab('history', this)"><span>History</span></div>
3334
+ </nav>
3335
+ <details id="tools-nav"><summary>Tools</summary>
3336
+ <nav class="nav" role="tablist" aria-label="Technical tools" aria-orientation="vertical">
3317
3337
  <div class="nav-item" data-tab="ledger" role="tab" tabindex="0" aria-label="Ledger" title="Ledger" onkeydown="navTabKeydown(event, this)" onclick="switchTab('ledger', this)">
3318
3338
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1v22 M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
3319
3339
  <span>Ledger</span>
@@ -3336,14 +3356,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3336
3356
  <span class="count" id="nav-harness-count"></span>
3337
3357
  </div>
3338
3358
  </nav>
3359
+ </details>
3339
3360
  <div class="nav-divider"></div>
3340
- <div class="nav-views-label" id="nav-views-label">Views</div>
3361
+ <div class="nav-views-label" id="nav-views-label" hidden>Views</div>
3341
3362
  <!-- Saved views over Fleet (DESIGN §5 point 2). Static for this shell —
3342
3363
  filtering the fleet list is the Fleet screen body's job (.8), not this
3343
3364
  task's; deliberately not `.nav-item`/`role="tab"`, since they neither
3344
3365
  switch a top-level destination nor do anything yet, and a control that
3345
3366
  looks live and isn't fails `ui-ux-pro-max` empty-nav-state. -->
3346
- <div class="nav-views">
3367
+ <div class="nav-views" hidden>
3347
3368
  <div class="nav-view-item" id="nav-view-needs" role="button" tabindex="0" onclick="setFleetView('needs')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('needs')}">Needs attention</div>
3348
3369
  <div class="nav-view-item" id="nav-view-expensive" role="button" tabindex="0" onclick="setFleetView('expensive')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('expensive')}">Expensive grants</div>
3349
3370
  <div class="nav-view-item" id="nav-view-never" role="button" tabindex="0" onclick="setFleetView('never')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('never')}">Never observed</div>
@@ -3398,8 +3419,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3398
3419
  </div>
3399
3420
  </div>
3400
3421
 
3422
+ <div class="panel active" id="panel-work" role="tabpanel">
3423
+ <div class="work-page"><h1>Work</h1>
3424
+ <form id="work-compose">
3425
+ <label for="work-goal">What result do you need?</label>
3426
+ <textarea id="work-goal" rows="3" required placeholder="Add CSV export with permission checks"></textarea>
3427
+ <label for="work-host">Execution host</label>
3428
+ <select id="work-host"><option value="claude-code">Claude Code</option><option value="codex">Controlled Codex</option></select>
3429
+ <div id="work-allow-row" hidden><label for="work-allow">Codex write scope (comma-separated paths)</label><input id="work-allow" placeholder="src,tests,docs" /></div>
3430
+ <p>Prepares a command. Run it in a terminal in the selected project. The board does not start a host session.</p>
3431
+ <button type="submit" class="gate-btn">Copy command</button><pre id="work-command"></pre>
3432
+ </form>
3433
+ <p id="work-feedback" role="status" aria-live="polite"></p>
3434
+ <div id="work-list">Loading project work…</div>
3435
+ </div>
3436
+ </div>
3437
+ <div class="panel" id="panel-history" role="tabpanel"><div class="work-page"><h1>History</h1><p>Closed issues and terminal runs. Closing a record does not prove task acceptance.</p><div id="history-list">Loading project work…</div></div></div>
3401
3438
  <!-- Inbox -->
3402
- <div class="panel active" id="panel-inbox">
3439
+ <div class="panel" id="panel-inbox">
3403
3440
  <div class="inbox-page">
3404
3441
  <!-- Fidelity pass (v3.27.1): the design's header is the screen's name and
3405
3442
  its count; the sentence the board used to shout in 52px display
@@ -3937,7 +3974,13 @@ const PRIORITY_LABEL = ['Urgent','High','Medium','Low'];
3937
3974
  let allTasks = [];
3938
3975
  let metrics = {};
3939
3976
  let shareState = { enabled: false, url: null };
3940
- let currentTab = 'inbox';
3977
+ let currentTab = 'work';
3978
+ window.GctoWork.bind({
3979
+ project: () => new URLSearchParams(location.search).get('project') || '',
3980
+ visible: () => currentTab === 'work' || currentTab === 'history',
3981
+ read: url => api(url),
3982
+ openIssue: (id, decision) => { if (decision) switchTab('decisions'); openSideById(id); },
3983
+ });
3941
3984
  let searchQuery = '';
3942
3985
  let allProjects = [];
3943
3986
  let currentProject = '';
@@ -5934,7 +5977,7 @@ async function renderHarnesses() {
5934
5977
 
5935
5978
  body.innerHTML = `
5936
5979
  <div class="mp-card harness-hosts">
5937
- <div><span class="dot ok"></span><b>Claude Code</b> <span class="mono cap muted">host · v${esc(h.claude_code?.version || '')}</span><span class="cap muted" style="margin-left:auto" title="71 agents · commands · skills · hooks">full pipeline</span></div>
5980
+ <div><span class="dot ok"></span><b>Claude Code</b> <span class="mono cap muted">host · v${esc(h.claude_code?.version || '')}</span><span class="cap muted" style="margin-left:auto" title="72 agents · commands · skills · hooks">full pipeline</span></div>
5938
5981
  <div><span class="${codexDot}"></span><b>OpenAI Codex</b> <span class="mono cap">${codexState}</span>${cx.version ? `<span class="mono cap muted">${esc(cx.version)}</span>` : ''}${cx.model ? `<span class="mono cap muted" style="margin-left:auto">${esc(cx.model)}</span>` : ''}</div>
5939
5982
  </div>
5940
5983
  <div class="mp-card" style="margin-top:12px">
@@ -7003,6 +7046,7 @@ async function switchProject(slug) {
7003
7046
  // Close existing SSE so it doesn't push the wrong project's data
7004
7047
  if (window._gctoSSE) { try { window._gctoSSE.close(); } catch {} window._gctoSSE = null; }
7005
7048
  currentProject = slug;
7049
+ window.GctoWork.reset();
7006
7050
  // Optimistic UI clear
7007
7051
  allTasks = [];
7008
7052
  renderKanban([]);
@@ -7069,6 +7113,7 @@ function filterTasks(q) {
7069
7113
  }
7070
7114
 
7071
7115
  function onSearch(q) {
7116
+ window.GctoWork.search(q);
7072
7117
  searchQuery = q;
7073
7118
  renderKanban(filterTasks(q));
7074
7119
  document.getElementById('topbar-task-count').textContent =
@@ -7184,12 +7229,13 @@ async function init() {
7184
7229
  // Build version badge (fire-and-forget — never blocks the board).
7185
7230
  api('/api/version').then(v => { const el = document.getElementById('build-version'); if (el && v && v.version) el.textContent = 'v' + v.version; }).catch(() => {});
7186
7231
 
7187
- const [tasks, met, share, projs, inbox] = await Promise.all([
7232
+ const [tasks, met, share, projs, inbox, serverWork] = await Promise.all([
7188
7233
  api(`/api/tasks${qs}`),
7189
7234
  api(`/api/metrics${qs}`),
7190
7235
  api(`/api/share${qs}`),
7191
7236
  api('/api/projects'),
7192
7237
  api(`/api/inbox${qs}`),
7238
+ api(`/api/work${qs}`),
7193
7239
  ]);
7194
7240
  allTasks = tasks || [];
7195
7241
  metrics = met || {};
@@ -7206,11 +7252,13 @@ async function init() {
7206
7252
  const fromTaskId = (allTasks[0]?.id || '').split('-')[0];
7207
7253
  const registered = (allProjects || []).some((p) => p.slug === fromTaskId) ? fromTaskId : '';
7208
7254
  const proj = urlProject
7255
+ || serverWork?.projectName
7209
7256
  || registered
7210
7257
  || (allProjects[0]?.slug)
7211
7258
  || fromTaskId
7212
7259
  || 'project';
7213
7260
  currentProject = proj;
7261
+ window.GctoWork.reset();
7214
7262
  document.getElementById('project-name').textContent = proj;
7215
7263
  document.getElementById('crumb-project').textContent = proj;
7216
7264
  document.title = `${proj} · great_cto`;
@@ -7388,6 +7436,7 @@ function connectSSE() {
7388
7436
  try { renderKanban(filterTasks(searchQuery)); } catch (e) { console.warn('SSE renderKanban failed', e); }
7389
7437
  checkBoardFreshness();
7390
7438
  refreshInbox();
7439
+ if (currentTab === 'work' || currentTab === 'history') window.GctoWork.refresh();
7391
7440
  };
7392
7441
  es.addEventListener('tasks', e => {
7393
7442
  const newTasks = JSON.parse(e.data);
@@ -8285,6 +8334,8 @@ let cmdkIndex = 0;
8285
8334
 
8286
8335
  function cmdkActions() {
8287
8336
  const out = [
8337
+ { group: 'Go', label: 'Work — goals and execution', run: () => switchTab('work') },
8338
+ { group: 'Go', label: 'History — recorded outcomes', run: () => switchTab('history') },
8288
8339
  { group: 'Go', label: 'Decisions — what needs you', run: () => switchTab('decisions') },
8289
8340
  { group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
8290
8341
  { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
@@ -8481,13 +8532,17 @@ try {
8481
8532
  // panels, not keys.
8482
8533
  const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
8483
8534
  const NAV = { inbox: 'decisions', budgets: 'ledger', agents: 'fleet', dashboard: 'ledger', share: 'settings', notifications: 'settings' };
8484
- const ROUTES = { decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
8535
+ const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
8485
8536
  function switchTab(id, el) {
8486
8537
  const lid = DEST[id] || id; // the panel's own name
8487
8538
  const navKey = NAV[id] || id; // the destination to highlight
8488
8539
  if (!el) el = document.querySelector(`.nav-item[data-tab="${navKey}"]`)
8489
8540
  || document.querySelector('.nav-item[data-tab="decisions"]');
8490
8541
  currentTab = lid;
8542
+ document.getElementById('nav-views-label').hidden = lid !== 'agents';
8543
+ document.querySelector('.nav-views').hidden = lid !== 'agents';
8544
+ if (['agents', 'budgets', 'harness'].includes(lid)) document.getElementById('tools-nav').open = true;
8545
+ if (lid === 'work' || lid === 'history') window.GctoWork.refresh();
8491
8546
  if (ROUTES[id]) {
8492
8547
  if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
8493
8548
  // BRD-R9: one local line per destination open, never blocking, never surfaced.
@@ -8507,7 +8562,7 @@ function switchTab(id, el) {
8507
8562
  // crumb — `labels` used to be referenced here but is only ever a function-local in
8508
8563
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
8509
8564
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
8510
- const TAB_LABELS = { decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
8565
+ const TAB_LABELS = { work: 'Work', history: 'History', decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
8511
8566
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
8512
8567
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
8513
8568
  // fetched when Metrics opens — not only from the agents drill-in it used to
@@ -8544,7 +8599,7 @@ function switchTab(id, el) {
8544
8599
 
8545
8600
  // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
8546
8601
  function navTabKeydown(event, el) {
8547
- const items = [...document.querySelectorAll('.nav-item[role="tab"]')];
8602
+ const items = [...document.querySelectorAll('.nav-item[role="tab"]')].filter(item => item.getClientRects().length);
8548
8603
  const i = items.indexOf(el);
8549
8604
  if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.click(); return; }
8550
8605
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@@ -8576,7 +8631,7 @@ function routeFromHash() {
8576
8631
  return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
8577
8632
  }
8578
8633
  addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
8579
- addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'decisions') switchTab(r); });
8634
+ addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'work') switchTab(r); });
8580
8635
 
8581
8636
  async function refreshLogs() {
8582
8637
  const data = await api(`/api/logs${pqs()}`);