great-cto 3.47.0 → 3.48.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
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",
4
+ "version": "3.48.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
  }
@@ -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
+ })();
@@ -378,7 +378,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
378
378
 
379
379
  /* The budgets panel. A cap with no consumption beside it cannot answer the only
380
380
  question worth asking of it, so every row carries the state and the spend. */
381
- .ab-title { font-weight: 600; margin-bottom: 8px; }
381
+ .ab-title { font-weight: 500; margin-bottom: 8px; }
382
382
  .ab-row {
383
383
  display: flex; align-items: center; gap: 10px;
384
384
  padding: 4px 0; border-top: 1px solid var(--border);
@@ -886,6 +886,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
886
886
  color: var(--text3);
887
887
  text-align: center;
888
888
  }
889
+ /* A <small> hint inside an empty state rendered at the browser's "smaller"
890
+ (13px × 5/6 = 10.83px), off the type scale; the layout gate caught it whenever
891
+ a fresh board had no logs. Pin it to the caption size. */
892
+ .empty small { font-size: var(--fs-caption); }
889
893
 
890
894
  /* A read failed. Deliberately louder than .empty — the whole point is that this
891
895
  must not be mistaken for "there is nothing here". */
@@ -2565,7 +2569,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2565
2569
  /* ── Agents installed list (legacy — kept for fallback) ───────────────── */
2566
2570
  .agents-installed { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin-top: 4px; }
2567
2571
  /* Agent budget bars (Paperclip cascading budget pattern) */
2568
- .ab-title { font-weight: 600; color: var(--text2); margin-bottom: 6px; }
2572
+ .ab-title { font-weight: 500; color: var(--text2); margin-bottom: 6px; }
2569
2573
  .ab-row { display: flex; justify-content: space-between; padding: 2px 0; }
2570
2574
  .ab-name { font-family: var(--mono, monospace); color: var(--accent-text); }
2571
2575
  .ab-cap { color: var(--text3); }
@@ -3261,9 +3265,27 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3261
3265
  ::-webkit-scrollbar { width: 10px; height: 10px; }
3262
3266
  ::-webkit-scrollbar-thumb { background: rgba(15, 17, 21, 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
3263
3267
  ::-webkit-scrollbar-thumb:hover { background: rgba(15, 17, 21, 0.22); border: 2px solid transparent; background-clip: content-box; }
3268
+ /* Task read-side: reuse the board's measured typography and colour tokens. */
3269
+ .nav-views[hidden], #nav-views-label[hidden], #work-allow-row[hidden] { display: none; }
3270
+ .work-page { padding: 24px; max-width: 1000px; }
3271
+ .work-card, #work-compose { padding: 16px; border: 1px solid var(--border); border-radius: 8px; margin: 12px 0; background: var(--bg-card); }
3272
+ .work-page h1 { font-size: var(--fs-title-l); font-weight: 500; }
3273
+ .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; }
3274
+ .work-page .gate-btn:hover { background: var(--bg-solid-hover); }
3275
+ .work-card a { color: var(--text); text-decoration: underline; }
3276
+ .work-card h2 { font-size: var(--fs-title-s); }
3277
+ .work-card p, #work-compose p { margin: 8px 0; font-size: var(--fs-body); }
3278
+ .work-meta { color: var(--text2); font-size: var(--fs-caption); }
3279
+ .work-warning { color: var(--absent-warn-fg); background: var(--bg-card); border: 1px solid var(--absent-warn-fg); padding: 12px; }
3280
+ .work-page pre { font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; margin: 12px 0; }
3281
+ #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; }
3282
+ #tools-nav summary { padding: 12px 16px; cursor: pointer; }
3283
+ @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; } }
3284
+ @media (max-width: 767px) { .work-page { padding: 12px; } .work-page button, #work-compose select { min-height: 44px; } }
3264
3285
  </style>
3265
3286
  </head>
3266
3287
  <body>
3288
+ <script src="/assets/work-view.js"></script>
3267
3289
  <div class="board">
3268
3290
  <!-- Subtle emerald glow background -->
3269
3291
  <div class="board-bg" aria-hidden="true"></div>
@@ -3299,21 +3321,17 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3299
3321
  <div id="proj-menu-list"></div>
3300
3322
  </div>
3301
3323
  </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
3324
  <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)">
3325
+ <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>
3326
+ <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
3327
  <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
3328
  <span>Decisions</span>
3315
3329
  <span class="count" id="nav-inbox-count">0</span>
3316
3330
  </div>
3331
+ <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>
3332
+ </nav>
3333
+ <details id="tools-nav"><summary>Tools</summary>
3334
+ <nav class="nav" role="tablist" aria-label="Technical tools" aria-orientation="vertical">
3317
3335
  <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
3336
  <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
3337
  <span>Ledger</span>
@@ -3336,14 +3354,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3336
3354
  <span class="count" id="nav-harness-count"></span>
3337
3355
  </div>
3338
3356
  </nav>
3357
+ </details>
3339
3358
  <div class="nav-divider"></div>
3340
- <div class="nav-views-label" id="nav-views-label">Views</div>
3359
+ <div class="nav-views-label" id="nav-views-label" hidden>Views</div>
3341
3360
  <!-- Saved views over Fleet (DESIGN §5 point 2). Static for this shell —
3342
3361
  filtering the fleet list is the Fleet screen body's job (.8), not this
3343
3362
  task's; deliberately not `.nav-item`/`role="tab"`, since they neither
3344
3363
  switch a top-level destination nor do anything yet, and a control that
3345
3364
  looks live and isn't fails `ui-ux-pro-max` empty-nav-state. -->
3346
- <div class="nav-views">
3365
+ <div class="nav-views" hidden>
3347
3366
  <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
3367
  <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
3368
  <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 +3417,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3398
3417
  </div>
3399
3418
  </div>
3400
3419
 
3420
+ <div class="panel active" id="panel-work" role="tabpanel">
3421
+ <div class="work-page"><h1>Work</h1>
3422
+ <form id="work-compose">
3423
+ <label for="work-goal">What result do you need?</label>
3424
+ <textarea id="work-goal" rows="3" required placeholder="Add CSV export with permission checks"></textarea>
3425
+ <label for="work-host">Execution host</label>
3426
+ <select id="work-host"><option value="claude-code">Claude Code</option><option value="codex">Controlled Codex</option></select>
3427
+ <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>
3428
+ <p>Prepares a command. Run it in a terminal in the selected project. The board does not start a host session.</p>
3429
+ <button type="submit" class="gate-btn">Copy command</button><pre id="work-command"></pre>
3430
+ </form>
3431
+ <p id="work-feedback" role="status" aria-live="polite"></p>
3432
+ <div id="work-list">Loading project work…</div>
3433
+ </div>
3434
+ </div>
3435
+ <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
3436
  <!-- Inbox -->
3402
- <div class="panel active" id="panel-inbox">
3437
+ <div class="panel" id="panel-inbox">
3403
3438
  <div class="inbox-page">
3404
3439
  <!-- Fidelity pass (v3.27.1): the design's header is the screen's name and
3405
3440
  its count; the sentence the board used to shout in 52px display
@@ -3937,7 +3972,13 @@ const PRIORITY_LABEL = ['Urgent','High','Medium','Low'];
3937
3972
  let allTasks = [];
3938
3973
  let metrics = {};
3939
3974
  let shareState = { enabled: false, url: null };
3940
- let currentTab = 'inbox';
3975
+ let currentTab = 'work';
3976
+ window.GctoWork.bind({
3977
+ project: () => new URLSearchParams(location.search).get('project') || '',
3978
+ visible: () => currentTab === 'work' || currentTab === 'history',
3979
+ read: url => api(url),
3980
+ openIssue: (id, decision) => { if (decision) switchTab('decisions'); openSideById(id); },
3981
+ });
3941
3982
  let searchQuery = '';
3942
3983
  let allProjects = [];
3943
3984
  let currentProject = '';
@@ -7003,6 +7044,7 @@ async function switchProject(slug) {
7003
7044
  // Close existing SSE so it doesn't push the wrong project's data
7004
7045
  if (window._gctoSSE) { try { window._gctoSSE.close(); } catch {} window._gctoSSE = null; }
7005
7046
  currentProject = slug;
7047
+ window.GctoWork.reset();
7006
7048
  // Optimistic UI clear
7007
7049
  allTasks = [];
7008
7050
  renderKanban([]);
@@ -7069,6 +7111,7 @@ function filterTasks(q) {
7069
7111
  }
7070
7112
 
7071
7113
  function onSearch(q) {
7114
+ window.GctoWork.search(q);
7072
7115
  searchQuery = q;
7073
7116
  renderKanban(filterTasks(q));
7074
7117
  document.getElementById('topbar-task-count').textContent =
@@ -7184,12 +7227,13 @@ async function init() {
7184
7227
  // Build version badge (fire-and-forget — never blocks the board).
7185
7228
  api('/api/version').then(v => { const el = document.getElementById('build-version'); if (el && v && v.version) el.textContent = 'v' + v.version; }).catch(() => {});
7186
7229
 
7187
- const [tasks, met, share, projs, inbox] = await Promise.all([
7230
+ const [tasks, met, share, projs, inbox, serverWork] = await Promise.all([
7188
7231
  api(`/api/tasks${qs}`),
7189
7232
  api(`/api/metrics${qs}`),
7190
7233
  api(`/api/share${qs}`),
7191
7234
  api('/api/projects'),
7192
7235
  api(`/api/inbox${qs}`),
7236
+ api(`/api/work${qs}`),
7193
7237
  ]);
7194
7238
  allTasks = tasks || [];
7195
7239
  metrics = met || {};
@@ -7206,11 +7250,13 @@ async function init() {
7206
7250
  const fromTaskId = (allTasks[0]?.id || '').split('-')[0];
7207
7251
  const registered = (allProjects || []).some((p) => p.slug === fromTaskId) ? fromTaskId : '';
7208
7252
  const proj = urlProject
7253
+ || serverWork?.projectName
7209
7254
  || registered
7210
7255
  || (allProjects[0]?.slug)
7211
7256
  || fromTaskId
7212
7257
  || 'project';
7213
7258
  currentProject = proj;
7259
+ window.GctoWork.reset();
7214
7260
  document.getElementById('project-name').textContent = proj;
7215
7261
  document.getElementById('crumb-project').textContent = proj;
7216
7262
  document.title = `${proj} · great_cto`;
@@ -7388,6 +7434,7 @@ function connectSSE() {
7388
7434
  try { renderKanban(filterTasks(searchQuery)); } catch (e) { console.warn('SSE renderKanban failed', e); }
7389
7435
  checkBoardFreshness();
7390
7436
  refreshInbox();
7437
+ if (currentTab === 'work' || currentTab === 'history') window.GctoWork.refresh();
7391
7438
  };
7392
7439
  es.addEventListener('tasks', e => {
7393
7440
  const newTasks = JSON.parse(e.data);
@@ -8285,6 +8332,8 @@ let cmdkIndex = 0;
8285
8332
 
8286
8333
  function cmdkActions() {
8287
8334
  const out = [
8335
+ { group: 'Go', label: 'Work — goals and execution', run: () => switchTab('work') },
8336
+ { group: 'Go', label: 'History — recorded outcomes', run: () => switchTab('history') },
8288
8337
  { group: 'Go', label: 'Decisions — what needs you', run: () => switchTab('decisions') },
8289
8338
  { group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
8290
8339
  { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
@@ -8481,13 +8530,17 @@ try {
8481
8530
  // panels, not keys.
8482
8531
  const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
8483
8532
  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 };
8533
+ const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
8485
8534
  function switchTab(id, el) {
8486
8535
  const lid = DEST[id] || id; // the panel's own name
8487
8536
  const navKey = NAV[id] || id; // the destination to highlight
8488
8537
  if (!el) el = document.querySelector(`.nav-item[data-tab="${navKey}"]`)
8489
8538
  || document.querySelector('.nav-item[data-tab="decisions"]');
8490
8539
  currentTab = lid;
8540
+ document.getElementById('nav-views-label').hidden = lid !== 'agents';
8541
+ document.querySelector('.nav-views').hidden = lid !== 'agents';
8542
+ if (['agents', 'budgets', 'harness'].includes(lid)) document.getElementById('tools-nav').open = true;
8543
+ if (lid === 'work' || lid === 'history') window.GctoWork.refresh();
8491
8544
  if (ROUTES[id]) {
8492
8545
  if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
8493
8546
  // BRD-R9: one local line per destination open, never blocking, never surfaced.
@@ -8507,7 +8560,7 @@ function switchTab(id, el) {
8507
8560
  // crumb — `labels` used to be referenced here but is only ever a function-local in
8508
8561
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
8509
8562
  // 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' };
8563
+ 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
8564
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
8512
8565
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
8513
8566
  // fetched when Metrics opens — not only from the agents drill-in it used to
@@ -8544,7 +8597,7 @@ function switchTab(id, el) {
8544
8597
 
8545
8598
  // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
8546
8599
  function navTabKeydown(event, el) {
8547
- const items = [...document.querySelectorAll('.nav-item[role="tab"]')];
8600
+ const items = [...document.querySelectorAll('.nav-item[role="tab"]')].filter(item => item.getClientRects().length);
8548
8601
  const i = items.indexOf(el);
8549
8602
  if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.click(); return; }
8550
8603
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@@ -8576,7 +8629,7 @@ function routeFromHash() {
8576
8629
  return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
8577
8630
  }
8578
8631
  addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
8579
- addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'decisions') switchTab(r); });
8632
+ addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'work') switchTab(r); });
8580
8633
 
8581
8634
  async function refreshLogs() {
8582
8635
  const data = await api(`/api/logs${pqs()}`);