great-cto 3.58.2 → 3.59.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.
@@ -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 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.58.2",
4
+ "version": "3.59.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -0,0 +1,26 @@
1
+ /** Ephemeral, single-use UI capability. Never stored inside a worker project. */
2
+ import { randomUUID } from 'node:crypto';
3
+ export function publicationTickets({ now = Date.now, ttl = 120000, limit = 128 } = {}) {
4
+ const tickets = new Map();
5
+ const prune = () => { for (const [id, t] of tickets) if (t.expiresAt <= now()) tickets.delete(id); };
6
+ return {
7
+ issue(binding) {
8
+ prune(); if (tickets.size >= limit) throw Error('publication preview capacity reached');
9
+ const id = randomUUID(); tickets.set(id, { ...binding, expiresAt: now() + ttl }); return id;
10
+ },
11
+ consume(id, { root, origin, branch }) {
12
+ prune(); const ticket = tickets.get(id);
13
+ if (!ticket || ticket.root !== root || ticket.origin !== origin || ticket.branch !== branch) throw Error('publication confirmation is missing, expired or bound to another project/origin/branch');
14
+ tickets.delete(id); return ticket;
15
+ },
16
+ };
17
+ }
18
+ export function localPublicationOrigin(req) {
19
+ const address = req.socket?.remoteAddress;
20
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return null;
21
+ const host = req.headers.host;
22
+ if (!/^(?:localhost|127\.0\.0\.1|\[::1\]):[0-9]+$/.test(host || '')) return null;
23
+ const expected = `http://${host}`;
24
+ if (req.headers.origin === expected || (!req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin')) return expected;
25
+ return null;
26
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { execFileSync } from 'child_process';
3
+ import { execFileSync, execFile } from 'child_process';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import os from 'os';
5
6
  import {
6
7
  getVapidKeys,
@@ -14,7 +15,7 @@ import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInf
14
15
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
16
  import { agentUsage, usageSnapshot } from '../../../scripts/lib/agent-usage.mjs';
16
17
  import { isProjectState } from '../../../scripts/lib/great-cto-scope.mjs';
17
- import { usageIndexSnapshot, summarizeUsage, readCodexTitles, readClaudeLimits, claudeRecorder } from '../../../scripts/lib/session-usage.mjs';
18
+ import { usageReports } from './usage-report.mjs';
18
19
  import { outcomes as computeOutcomes } from '../../../scripts/lib/outcomes.mjs';
19
20
  import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
20
21
  import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
@@ -44,6 +45,19 @@ import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../.
44
45
  import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
45
46
  import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
46
47
  import { getWork } from './work.mjs';
48
+ let publicationPreviewBusy = false;
49
+ let publicationWriteBusy = false;
50
+ import { publicationTickets, localPublicationOrigin } from './publication-tickets.mjs';
51
+ const publicationTicketStore = publicationTickets();
52
+
53
+ function publicationWorker(args, cwd, timeout = 20000) {
54
+ const controller = fileURLToPath(new URL('../../../scripts/work-task.mjs', import.meta.url));
55
+ return new Promise((resolve, reject) => execFile(process.execPath, [controller, ...args, '--dir', cwd],
56
+ { timeout, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (error, stdout) => {
57
+ if (error) { reject(Error('publication worker failed; inspect host state before retry')); return; }
58
+ try { resolve(JSON.parse(stdout)); } catch { reject(Error('publication response is unreadable')); }
59
+ }));
60
+ }
47
61
  import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
48
62
  import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
49
63
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
@@ -103,6 +117,60 @@ async function dispatch(req, res, url, cwd) {
103
117
  }
104
118
  }
105
119
 
120
+ if (pathname === '/api/work/publication') {
121
+ const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
122
+ if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
123
+ if (req.method !== 'POST') { respond(405, { error: 'Use explicit POST confirmation' }); return true; }
124
+ const origin = localPublicationOrigin(req);
125
+ // A missing Origin is acceptable for preview, never for an external write.
126
+ if (!origin || req.headers.origin !== origin || !originAllowed(req)) { respond(403, { error: 'Publication is available only from the local same-origin board' }); return true; }
127
+ if (publicationWriteBusy) { respond(429, { error: 'Another publication is running; inspect its outcome before retry' }); return true; }
128
+ publicationWriteBusy = true;
129
+ try {
130
+ let bytes = 0, body = '';
131
+ req.setTimeout(10000, () => req.destroy());
132
+ for await (const chunk of req) { bytes += chunk.length; if (bytes > 2048) throw Error('confirmation body too large'); body += chunk; }
133
+ req.setTimeout(0);
134
+ const parsed = JSON.parse(body);
135
+ if (parsed.confirm !== 'publish-draft-pr') throw Error('explicit draft PR confirmation required');
136
+ const ticket = publicationTicketStore.consume(parsed.ticket, { root: fs.realpathSync(cwd), origin, branch: parsed.branch });
137
+ const result = await publicationWorker(['publish', '--task', ticket.taskId, '--revision', String(ticket.expectedRevision),
138
+ '--approval', ticket.approval, '--base', ticket.base, '--allow', ticket.allow.join(','), '--confirm', 'publish-draft-pr'], cwd, 120000);
139
+ respond(200, { publication: result.task.publication });
140
+ } catch { respond(409, { error: 'Publication was not confirmed. Refresh task state and reconcile the original operation; no automatic retry was sent.' }); }
141
+ finally { publicationWriteBusy = false; }
142
+ return true;
143
+ }
144
+
145
+ // Preview never pushes. Local UI receives a short-lived confirmation ticket.
146
+ if (pathname === '/api/work/publication-preview') {
147
+ if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
148
+ res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unknown project' })); return true;
149
+ }
150
+ if (req.method !== 'GET') {
151
+ res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }); res.end(JSON.stringify({ error: 'Publication requires explicit host CLI confirmation' })); return true;
152
+ }
153
+ if (publicationPreviewBusy) {
154
+ res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '2' });
155
+ res.end(JSON.stringify({ error: 'Another publication preview is being read; retry shortly' })); return true;
156
+ }
157
+ publicationPreviewBusy = true;
158
+ try {
159
+ const taskId = url.searchParams.get('task'), base = url.searchParams.get('base') || 'main', allow = url.searchParams.get('allow');
160
+ if (!/^[0-9a-f-]{36}$/.test(taskId || '') || !/^[A-Za-z0-9][A-Za-z0-9/_-]{0,120}$/.test(base)) throw Error('invalid preview options');
161
+ // Git/receipt reads run off the board event loop, with one bounded worker.
162
+ const preview = await publicationWorker(['preview', '--task', taskId, '--base', base, ...(allow ? ['--allow', allow] : [])], cwd);
163
+ const origin = localPublicationOrigin(req);
164
+ if (origin) preview.ticket = publicationTicketStore.issue({ root: fs.realpathSync(cwd), origin, taskId,
165
+ branch: preview.branch, base: preview.base, allow: preview.allow, ...preview.confirmation });
166
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(preview));
167
+ } catch {
168
+ res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
169
+ res.end(JSON.stringify({ error: 'Publication preview unavailable: require verified delivery, a clean committed feature branch, explicit path scope and canonical GitHub origin. Inspect with the host CLI.' }));
170
+ } finally { publicationPreviewBusy = false; }
171
+ return true;
172
+ }
173
+
106
174
  // New read model refuses project fallback rather than relabeling another project.
107
175
  if (pathname === '/api/work') {
108
176
  if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
@@ -1510,12 +1578,12 @@ async function dispatch(req, res, url, cwd) {
1510
1578
  if (pathname === '/api/usage') {
1511
1579
  const rawDays = parseInt(url.searchParams.get('days') || '30', 10);
1512
1580
  const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30;
1513
- const snap = boardSessionUsage().get();
1581
+ const snap = boardSessionUsage().get(days);
1514
1582
  let body;
1515
- if (snap.state !== 'ready') {
1583
+ if (snap.state !== 'counted') {
1516
1584
  body = { state: snap.state, why: snap.why };
1517
1585
  } else {
1518
- const sum = summarizeUsage(snap.index, { days, codexTitles: readCodexTitles(), claudeReadings: readClaudeLimits(), claudeRecorderState: claudeRecorder() });
1586
+ const sum = snap;
1519
1587
  const ours = new Set(boardAgentNames());
1520
1588
  for (const host of Object.keys(sum.lists)) {
1521
1589
  sum.lists[host].agents = sum.lists[host].agents.map((a) => ({ ...a, ours: ours.has(a.name) }));
@@ -2250,7 +2318,7 @@ function boardOutcomes(days) {
2250
2318
 
2251
2319
  let _sessionUsageSnap = null;
2252
2320
  function boardSessionUsage() {
2253
- if (!_sessionUsageSnap) _sessionUsageSnap = usageIndexSnapshot();
2321
+ if (!_sessionUsageSnap) _sessionUsageSnap = usageReports();
2254
2322
  return _sessionUsageSnap;
2255
2323
  }
2256
2324
 
@@ -0,0 +1,21 @@
1
+ /** Read-only activity projection. Session identity, never title/role, owns linkage. */
2
+ export function taskInspector(sessions, activity = { state: 'none', events: [] }) {
3
+ const linked = new Set(sessions.filter(s => typeof s === 'string' && s));
4
+ const events = (activity.events || []).filter(e => linked.has(e.session)).slice(-20)
5
+ .map(e => ({ ts: typeof e.ts === 'string' ? e.ts : null,
6
+ kind: typeof e.kind === 'string' ? e.kind : null,
7
+ agent: typeof e.agent === 'string' ? e.agent.slice(0, 80) : null,
8
+ host: ['claude', 'codex'].includes(e.host) ? e.host : null,
9
+ tool: typeof e.tool === 'string' ? e.tool.slice(0, 80) : null,
10
+ outcome: typeof e.outcome === 'string' ? e.outcome.slice(0, 80) : null,
11
+ verdict: typeof e.verdict === 'string' ? e.verdict.slice(0, 80) : null,
12
+ ok: typeof e.ok === 'boolean' ? e.ok : null,
13
+ durationMs: Number.isFinite(e.duration_ms) && e.duration_ms >= 0 ? e.duration_ms : null }));
14
+ return { state: !linked.size ? 'unlinked' : activity.state === 'unreadable' ? 'unreadable'
15
+ : events.length ? 'recorded' : 'none', events,
16
+ partial: (activity.bad || 0) > 0 || activity.gap === true,
17
+ lastEventAt: events.at(-1)?.ts || null,
18
+ reason: !linked.size ? 'No explicit execution session link'
19
+ : activity.state === 'unreadable' ? 'Project activity could not be read'
20
+ : !events.length ? 'No linked events in the bounded project journal window' : null };
21
+ }
@@ -0,0 +1,9 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { scanUsage, summarizeUsage, readCodexTitles, readClaudeLimits, claudeRecorder } from '../../../scripts/lib/session-usage.mjs';
3
+ try {
4
+ const snapshot = await scanUsage();
5
+ const report = summarizeUsage(snapshot.index, { days: workerData.days, codexTitles: readCodexTitles(), claudeReadings: readClaudeLimits(), claudeRecorderState: claudeRecorder() });
6
+ parentPort.postMessage({ ...report, seen: snapshot.seen, files: snapshot.files });
7
+ } catch {
8
+ parentPort.postMessage({ state: 'unavailable', why: 'Session statistics could not be read; retry or inspect local logs.' });
9
+ }
@@ -0,0 +1,45 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ function computeReport(days) {
4
+ return new Promise((resolve, reject) => {
5
+ const worker = new Worker(new URL('./usage-report-worker.mjs', import.meta.url), { workerData: { days } });
6
+ worker.unref();
7
+ let settled = false;
8
+ const finish = (error, result) => {
9
+ if (settled) return;
10
+ settled = true; clearTimeout(timer); worker.terminate();
11
+ if (error) reject(error); else resolve(result);
12
+ };
13
+ const timer = setTimeout(() => finish(Error('statistics deadline exceeded')), 120000);
14
+ timer.unref();
15
+ worker.once('message', result => finish(null, result));
16
+ worker.once('error', error => finish(error));
17
+ worker.once('exit', () => finish(Error('statistics worker exited without a result')));
18
+ });
19
+ }
20
+
21
+ /** Bounded stale-while-refresh cache. One scan at a time, off the HTTP thread. */
22
+ export function usageReports({ compute = computeReport, now = Date.now, ttlMs = 60000, limit = 8 } = {}) {
23
+ const slots = new Map(); let running = null;
24
+ return {
25
+ get(days) {
26
+ let slot = slots.get(days);
27
+ if (!slot) {
28
+ if (slots.size >= limit) slots.delete(slots.keys().next().value);
29
+ slot = { value: null, at: 0 }; slots.set(days, slot);
30
+ }
31
+ if ((!slot.value || now() - slot.at >= ttlMs) && !running) {
32
+ running = Promise.resolve().then(() => compute(days)).then(value => {
33
+ if (value?.state === 'counted') { slot.value = value; slot.error = null; }
34
+ else if (!slot.value) slot.value = { state: 'unavailable', why: value?.why || 'Statistics could not be read.' };
35
+ else slot.error = value?.why || 'Statistics refresh failed.';
36
+ }, () => {
37
+ if (!slot.value) slot.value = { state: 'unavailable', why: 'Statistics worker failed; retry after a minute.' };
38
+ else slot.error = 'Statistics refresh failed.';
39
+ }).finally(() => { slot.at = now(); running = null; });
40
+ }
41
+ if (!slot.value) return { state: 'computing', why: 'Reading session statistics in a background worker; navigation remains available.' };
42
+ return { ...slot.value, refreshing: !!running, stale: !!slot.error || now() - slot.at >= ttlMs, refreshError: slot.error || null };
43
+ },
44
+ };
45
+ }
@@ -8,6 +8,8 @@ import { listWorkTasks, publicWorkTask } from '../../../scripts/lib/work-tasks.m
8
8
  import { decisionCapabilities } from '../../../scripts/lib/work-decisions.mjs';
9
9
  import { measureWork, compareWork } from '../../../scripts/lib/work-metrics.mjs';
10
10
  import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
11
+ import { agentActivity } from './agent-activity.mjs';
12
+ import { taskInspector } from './task-inspector.mjs';
11
13
 
12
14
  const text = v => typeof v === 'string' ? v : null;
13
15
  const date = v => typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null;
@@ -15,7 +17,7 @@ const RUN_PHASE = { ready: 'accepted', 'awaiting-gate': 'needs_decision',
15
17
  'awaiting-release': 'needs_decision', blocked: 'blocked', 'manual-action': 'blocked' };
16
18
 
17
19
  export function projectWork({ projectId, issues = [], codex = { state: 'absent', runs: [] },
18
- tasks = [], sessions = [], sources = [], observedAt = new Date().toISOString() }) {
20
+ tasks = [], sessions = [], sources = [], activity = { state: 'none', events: [] }, observedAt = new Date().toISOString() }) {
19
21
  const entries = [];
20
22
  for (const r of codex.runs || []) {
21
23
  // Completion of a controller run is not proof of the user's acceptance criteria.
@@ -38,6 +40,8 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
38
40
  : terminal ? 'This run is terminal' : 'This state does not permit ordinary resume' }],
39
41
  command: canCopyResume ? `great-cto resume ${r.id} --host codex` : null,
40
42
  evidence: (r.rolesCompleted || []).map(role => ({ kind: 'recorded_role', label: role })),
43
+ stage: text(r.active) || text(r.pending?.role) || (r.wave?.status === 'running' && Array.isArray(r.wave.roles) ? r.wave.roles.filter(role => typeof role === 'string').join(', ') : null),
44
+ inspector: taskInspector([r.id], activity),
41
45
  release: r.release ? { status: text(r.release.status), url: text(r.release.url), verifiedAt: date(r.release.verifiedAt) } : null,
42
46
  });
43
47
  }
@@ -52,6 +56,7 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
52
56
  decisions: pending ? [{ id: t.id, engine: 'beads', label: 'Review decision' }] : [],
53
57
  capabilities: [{ action: pending ? 'review_decision' : 'view_issue', enabled: true, reason: null }],
54
58
  command: null, evidence: [], release: null,
59
+ inspector: taskInspector([], activity),
55
60
  });
56
61
  }
57
62
  for (const t of tasks) {
@@ -75,6 +80,10 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
75
80
  intent: t.intent || 'delivery', stage: t.stage || null, taskOutcome: t.outcome || null,
76
81
  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
82
  release: run?.release || null, revision: t.revision, metrics: t.metrics,
83
+ inspector: taskInspector([...t.links.runs, ...t.links.sessions], activity),
84
+ publication: t.publication || null,
85
+ publicationAllow: Array.isArray(t.authority?.writeScope) ? t.authority.writeScope.join(',') : '',
86
+ publicationPreview: t.managed !== false && t.intent !== 'research' && t.phase === 'verified' && !owner && !t.decisions?.length,
78
87
  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
88
  : t.managed === false ? 'Continue this observed session inside its native host'
80
89
  : t.phase === 'needs_decision' ? 'Resolve the native host decision first' : 'Execution link or resumable host state is unavailable' }],
@@ -98,8 +107,13 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
98
107
 
99
108
  export function getWork(cwd) {
100
109
  const observedAt = new Date().toISOString();
110
+ let activity;
111
+ try { activity = agentActivity(cwd, { limit: 200 }); }
112
+ catch { activity = { state: 'unreadable', events: [] }; }
101
113
  const sources = []; let issues = [], codex = { state: 'absent', runs: [] }, sessions = [], tasks = [];
102
114
  const source = (id, health, reason = null) => sources.push({ id, health, reason, observedAt });
115
+ source('activity', activity.state === 'unreadable' || activity.bad ? 'degraded' : 'current',
116
+ activity.state === 'unreadable' ? 'Cannot read project activity' : activity.bad ? 'Some activity records are malformed' : null);
103
117
  try {
104
118
  const listing = listWorkTasks(cwd); tasks = listing.tasks.map(publicWorkTask);
105
119
  source('tasks', listing.state === 'degraded' ? 'degraded' : 'current', listing.unreadable ? `${listing.unreadable} task state file(s) could not be read` : null);
@@ -132,5 +146,5 @@ export function getWork(cwd) {
132
146
  let canonical = cwd;
133
147
  try { canonical = realpathSync(cwd); } catch { /* source health already reports inaccessible data */ }
134
148
  const projectId = 'project:' + createHash('sha256').update(canonical).digest('hex');
135
- return { ...projectWork({ projectId, issues, codex, tasks, sessions, sources, observedAt }), projectName: basename(cwd) };
149
+ return { ...projectWork({ projectId, issues, codex, tasks, sessions, sources, activity, observedAt }), projectName: basename(cwd) };
136
150
  }
@@ -19,13 +19,21 @@
19
19
  const cap = e.capabilities.find(c => c.enabled);
20
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
21
  return `<article class="work-card" data-entry-key="${esc(e.key)}"><h2>${esc(e.title)}</h2><p>${esc(phaseLabel(e))}</p>`
22
+ + `<p>Stage: ${esc(e.stage || 'not recorded')}</p>`
22
23
  + `<p class="work-meta">${esc(e.host || 'Host not linked')} · ${e.updatedAt ? esc(e.updatedAt) : 'Update time not recorded'}</p>`
23
24
  + (e.acceptance.length ? `<p>Acceptance: ${esc(e.acceptance.join('; '))}</p>` : '')
24
25
  + `<p>${esc(e.reason || 'Execution observation is recorded; acceptance is not inferred.')}</p>`
25
26
  + e.decisions.map(d => `<p>${esc(d.label)}${d.engine === 'codex' ? ' · resolve in the host controller' : ''}</p>`).join('')
27
+ + `<p>Next action: ${esc(cap ? cap.action === 'copy_approve' || cap.action === 'review_decision' ? 'Review the pending decision' : cap.action === 'copy_resume' ? 'Resume in the host controller' : 'Inspect the linked issue' : e.capabilities[0]?.reason || 'No supported action recorded')}</p>`
26
28
  + action + (e.command ? `<pre>${esc(e.command)}</pre>` : '')
27
29
  + (!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>`
30
+ + renderInspector(e.inspector)
31
+ + (e.publicationPreview ? `<label>Publication paths (comma-separated) <input data-publication-allow="${esc(e.key)}" value="${esc(e.publicationAllow || '')}" placeholder="src,tests"></label><button type="button" class="gate-btn" data-work-key="${esc(e.key)}" data-work-action="preview_publication">Preview draft PR publication</button><div data-publication-preview="${esc(e.key)}"></div>` : '')
32
+ + (e.publication ? `<p>PR publication: ${esc(e.publication.state)} · ${esc(e.publication.head || '')}</p>`
33
+ + (typeof e.publication.url === 'string' && /^https:\/\/github\.com\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/.test(e.publication.url) ? `<a href="${esc(e.publication.url)}" target="_blank" rel="noopener noreferrer">Open draft PR result</a>` : '')
34
+ + (e.publication.lastError ? `<p>${esc(e.publication.lastError)}</p>` : '') : '')
35
+ + (e.publication?.resumeCommand ? `<p>Reconcile the original publication in the selected project; do not create another PR blindly.</p><pre>${esc(e.publication.resumeCommand)}</pre>` : '')
36
+ + `<details data-work-detail="technical"><summary>Technical details</summary><p>${esc(e.key)} · ${esc(e.nativeState || 'unknown')}</p>`
29
37
  + `<p>Task linkage: ${esc(e.taskId || 'not recorded')}</p>${e.revision ? `<p>Task revision: ${esc(e.revision)}</p>` : ''}`
30
38
  + (e.taskOutcome ? `<p>Outcome evidence: ${esc(e.taskOutcome.source || 'operator-attestation')} · ${esc(e.taskOutcome.state)}</p>` : '')
31
39
  + `<p>Recorded roles: ${esc(e.evidence.map(v => v.label).join(', ') || 'none recorded; verification is not inferred')}</p>`
@@ -35,6 +43,61 @@
35
43
  + (!history && snapshot.sessions.length ? `<details><summary>Claude sessions (${snapshot.sessions.length}) · task linkage not recorded</summary>`
36
44
  + snapshot.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('') + '</details>' : '');
37
45
  }
46
+ function renderInspector(inspector) {
47
+ if (!inspector) return '';
48
+ return `<details data-work-detail="inspector"><summary>Live activity inspector · read-only</summary>`
49
+ + `<p>Recent recorded events, refreshed with this view. Activity is not proof of liveness or acceptance. Prompts, raw output and terminal input are not exposed.</p>`
50
+ + (inspector.reason ? `<p>${esc(inspector.reason)}</p>` : '')
51
+ + (inspector.partial ? '<p class="work-warning">Activity window is incomplete.</p>' : '')
52
+ + `<p>Last linked event: ${esc(inspector.lastEventAt || 'not recorded')}</p>`
53
+ + (inspector.events?.length ? '<ol>' + inspector.events.map(e => `<li>${esc(e.ts || 'time unknown')} · ${esc(e.host || 'host unknown')} · ${esc(e.agent || 'agent unknown')} · ${esc(e.kind || 'event')}`
54
+ + (e.tool ? ` · ${esc(e.tool)}` : '') + (e.outcome ? ` · outcome: ${esc(e.outcome)}` : '')
55
+ + (e.verdict ? ` · verdict: ${esc(e.verdict)}` : '')
56
+ + (e.durationMs !== null ? ` · ${esc(e.durationMs)} ms` : '') + '</li>').join('') + '</ol>' : '')
57
+ + '</details>';
58
+ }
59
+ function publicationCommand(preview) {
60
+ const confirmation = preview.confirmation || { expectedRevision: preview.revision, approval: preview.approval };
61
+ if (!/^[0-9a-f-]{36}$/.test(preview.taskId || '') || !Number.isInteger(confirmation.expectedRevision) || confirmation.expectedRevision < 1
62
+ || !/^[0-9a-f]{64}$/.test(confirmation.approval || '') || !Array.isArray(preview.allow) || preview.allow.some(p => typeof p !== 'string' || p.includes(','))) throw Error('Invalid publication preview. Use the host CLI.');
63
+ return `great-cto task work publish --task ${preview.taskId} --revision ${confirmation.expectedRevision} --approval ${confirmation.approval} --base ${quote(preview.base)} --allow ${quote(preview.allow.join(','))} --confirm publish-draft-pr`;
64
+ }
65
+ async function previewPublication(entry) {
66
+ const project = config.project(), seq = generation;
67
+ const panel = [...document.querySelectorAll('[data-publication-preview]')].find(n => n.dataset.publicationPreview === entry.key);
68
+ if (!panel) return;
69
+ panel.textContent = 'Reading publication preview…';
70
+ try {
71
+ const allow = [...document.querySelectorAll('[data-publication-allow]')].find(n => n.dataset.publicationAllow === entry.key)?.value?.trim();
72
+ if (!allow) throw Error('Enter an explicit publication path scope first.');
73
+ const preview = await config.read(`/api/work/publication-preview?task=${encodeURIComponent(entry.taskId)}&allow=${encodeURIComponent(allow)}${project ? '&project=' + encodeURIComponent(project) : ''}`);
74
+ if (project !== config.project() || seq !== generation || !panel.isConnected) return;
75
+ if (!preview || preview.error || preview.taskId !== entry.taskId || preview.revision !== entry.revision) throw Error(preview?.error || 'Task changed; refresh before publication.');
76
+ const command = publicationCommand(preview);
77
+ previews.set(entry.key, { preview, project });
78
+ panel.innerHTML = `<p>${esc(preview.repository)} · ${esc(preview.branch)} → ${esc(preview.base)} · SHA ${esc(preview.head)}</p>`
79
+ + `<p>${esc(preview.scope)}</p><pre>${esc(preview.summary)}</pre><details><summary>Review every commit being published</summary><pre>${esc(preview.patch)}</pre></details>`
80
+ + (preview.ticket && config.publish ? `<label>Type ${esc(preview.branch)} to confirm draft PR publication <input data-publication-confirm="${esc(entry.key)}" autocomplete="off"></label><button type="button" class="gate-btn" data-work-key="${esc(entry.key)}" data-work-action="publish_publication">Publish approved draft PR</button>` : '<p>Browser publication is unavailable on this connection. Confirm using the host CLI.</p>')
81
+ + `<p>Only push and draft PR are authorized. No commit, merge, release or deploy.</p><pre>${esc(command)}</pre>`;
82
+ } catch (error) {
83
+ if (project === config.project() && seq === generation && panel.isConnected) panel.textContent = error.message;
84
+ }
85
+ }
86
+ const previews = new Map();
87
+ async function publish(entry, button) {
88
+ const saved = previews.get(entry.key), project = config.project();
89
+ if (!saved || saved.project !== project || !saved.preview.ticket || !config.publish) return;
90
+ const branch = [...document.querySelectorAll('[data-publication-confirm]')].find(n => n.dataset.publicationConfirm === entry.key)?.value;
91
+ if (branch !== saved.preview.branch) { status('Type the exact preview branch to authorize publication.'); return; }
92
+ button.disabled = true; previews.delete(entry.key); status('Publishing the approved candidate; do not retry until its outcome is known.');
93
+ try {
94
+ const result = await config.publish(`/api/work/publication${project ? '?project=' + encodeURIComponent(project) : ''}`, { ticket: saved.preview.ticket, branch, confirm: 'publish-draft-pr' });
95
+ if (project !== config.project()) return;
96
+ if (!result || result.error || result.publication?.state !== 'pr-linked' || !/^https:\/\/github\.com\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/.test(result.publication?.url || ''))
97
+ throw Error(result?.error || 'Publication result is not linked to a draft PR; reconcile task state.');
98
+ status('Draft PR publication recorded. Merge and release remain separate decisions.'); await refresh();
99
+ } catch (error) { if (project === config.project()) { status(error.message); await refresh(); } }
100
+ }
38
101
  let config, snapshot, generation = 0, filter = '';
39
102
  const status = message => { document.getElementById('work-feedback').textContent = message; };
40
103
  async function refresh() {
@@ -53,16 +116,18 @@
53
116
  }
54
117
  function paint() {
55
118
  if (!snapshot) return;
56
- const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => d.closest('[data-entry-key]').dataset.entryKey);
119
+ previews.clear(); // Repaint removes the reviewed diff and its confirmation controls.
120
+ const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => `${d.closest('[data-entry-key]').dataset.entryKey}:${d.dataset.workDetail}`);
57
121
  const focused = document.activeElement;
58
122
  const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
59
123
  document.getElementById('work-list').innerHTML = render(snapshot, false, filter);
60
124
  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;
125
+ for (const card of document.querySelectorAll('[data-entry-key]')) for (const detail of card.querySelectorAll('details'))
126
+ if (expanded.includes(`${card.dataset.entryKey}:${detail.dataset.workDetail}`)) detail.open = true;
62
127
  if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
63
128
  }
64
129
  function reset() {
65
- generation++; snapshot = null; status('');
130
+ generation++; snapshot = null; previews.clear(); status('');
66
131
  for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = 'Loading project work…';
67
132
  refresh();
68
133
  }
@@ -86,6 +151,8 @@
86
151
  for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
87
152
  const button = event.target.closest('[data-work-action]'); if (!button || !snapshot) return;
88
153
  const entry = snapshot.entries.find(e => e.key === button.dataset.workKey);
154
+ if (button.dataset.workAction === 'preview_publication' && entry?.publicationPreview) { previewPublication(entry); return; }
155
+ if (button.dataset.workAction === 'publish_publication' && entry?.publicationPreview) { publish(entry, button); return; }
89
156
  const action = entry?.capabilities.find(c => c.action === button.dataset.workAction && c.enabled);
90
157
  if (!action) return;
91
158
  if (action.action === 'copy_resume' || action.action === 'copy_approve') copy(entry.command);
@@ -93,5 +160,5 @@
93
160
  });
94
161
  setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
95
162
  }
96
- window.GctoWork = { bind, refresh, reset, render, commandFor, search(value) { filter = value; paint(); } };
163
+ window.GctoWork = { bind, refresh, reset, render, commandFor, publicationCommand, search(value) { filter = value; paint(); } };
97
164
  })();
@@ -561,19 +561,23 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
561
561
  color: var(--text3);
562
562
  }
563
563
 
564
- .nav { padding: 12px 8px; flex: 1; }
565
- .nav-item {
566
- display: flex; align-items: center; gap: 10px;
567
- height: 32px; padding: 0 10px;
564
+ .sidebar { --nav-row-height: 36px; --nav-row-pad: 10px; --nav-icon-size: 16px; --nav-row-gap: 10px; }
565
+ .nav { padding: 8px; flex: 0 0 auto; display: flex; flex-direction: column; gap: 2px; }
566
+ /* One row pattern for destinations, disclosure and Fleet filters. */
567
+ .nav-item, .nav-view-item, .nav-group-toggle {
568
+ display: flex; align-items: center; gap: var(--nav-row-gap);
569
+ height: var(--nav-row-height); padding: 0 var(--nav-row-pad);
568
570
  border-radius: 0;
569
571
  font-size: var(--fs-body);
572
+ font-weight: 400;
570
573
  color: var(--text);
571
574
  cursor: pointer;
572
575
  position: relative;
573
576
  user-select: none;
574
577
  }
575
- .nav-item:hover { background: var(--bg-muted); }
576
- .nav-item.active { background: var(--bg-strong); font-weight: 500; }
578
+ .nav-item:hover, .nav-view-item:hover, .nav-group-toggle:hover { background: var(--bg-muted); }
579
+ .nav-item.active, .nav-view-item.active { background: var(--bg-strong); font-weight: 500; }
580
+ .nav-item:focus-visible, .nav-view-item:focus-visible, .nav-group-toggle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
577
581
  .nav-item.active::before {
578
582
  content: "";
579
583
  position: absolute;
@@ -582,15 +586,22 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
582
586
  background: var(--accent);
583
587
  border-radius: 0;
584
588
  }
585
- .nav-item .icon { width: 16px; height: 16px; color: var(--text2); flex-shrink: 0; }
586
- .nav-item.active .icon { color: var(--accent-text); }
587
- .nav-item .count { margin-left: auto; font-size: var(--fs-caption); color: var(--text3); font-family: var(--mono); }
589
+ .sidebar .icon { width: var(--nav-icon-size); height: var(--nav-icon-size); color: var(--text2); flex-shrink: 0; }
590
+ .nav-item.active .icon, .nav-view-item.active .icon { color: var(--accent-text); }
591
+ .nav-label { flex: 1; min-width: 0; }
592
+ .nav-item .count, .nav-view-count { margin-left: auto; font-size: var(--fs-caption); color: var(--text3); font-family: var(--mono); font-variant-numeric: tabular-nums; }
593
+ #tools-nav { flex: 0 0 auto; }
594
+ #tools-nav > summary { margin: 0 8px; list-style: none; }
595
+ #tools-nav > summary::-webkit-details-marker { display: none; }
596
+ #tools-nav > summary::marker { content: ''; }
597
+ #tools-nav[open] > summary .icon { transform: rotate(90deg); }
598
+ #tools-nav .nav { padding-top: 2px; }
588
599
  .nav-divider { height: 1px; background: var(--border); margin: 12px 8px; }
589
600
  .nav-views-label { padding: 0 18px; font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; }
590
601
  .nav-views { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px 12px; }
591
- .nav-view-item { height: 28px; display: flex; align-items: center; padding: 0 10px; font-size: var(--fs-small); color: var(--text3); }
592
602
 
593
603
  .sidebar-footer {
604
+ margin-top: auto;
594
605
  padding: 12px 16px;
595
606
  border-top: 1px solid var(--border);
596
607
  font-family: var(--mono);
@@ -1511,10 +1522,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1511
1522
  .bar { height: 6px; background: var(--bg-strong); border-radius: 999px; overflow: hidden; }
1512
1523
  .bar > i { display: block; height: 100%; background: var(--accent); }
1513
1524
  /* Fleet (great_cto-ki1x.8): saved views in the sidebar, domain groups, AgentRow cells */
1514
- .nav-view-item { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
1515
- .nav-view-item:hover { background: var(--bg-muted); color: var(--text); }
1516
- .nav-view-item.active { background: var(--bg-strong); color: var(--text); font-weight: 500; }
1517
- .nav-view-count { font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); font-variant-numeric: tabular-nums; }
1518
1525
  .fleet-group-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 4px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); font-weight: 500; }
1519
1526
  .fleet-group-collapsed { display: flex; flex-wrap: wrap; gap: 20px; padding: 10px 12px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
1520
1527
  .ar-cells { display: grid; grid-template-columns: minmax(200px, 1.4fr) 130px 48px 56px 80px auto; gap: 12px; align-items: center; margin-top: 4px; }
@@ -3159,7 +3166,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3159
3166
  operator caused, none for state the machine caused. Hover and focus 90ms
3160
3167
  linear; the drawer 200ms out / 140ms in (exit faster than enter); SSE
3161
3168
  arrival 0ms — rows and figures swap, they do not animate in. ── */
3162
- .nav-item, .nav-view-item, .agent-row, .inbox-row, .gate-btn, .btn-black, .btn-ghost-sm, .pill, .so-option { transition: background-color 90ms linear, border-color 90ms linear, outline-color 90ms linear; }
3169
+ .nav-item, .nav-view-item, .nav-group-toggle, .agent-row, .inbox-row, .gate-btn, .btn-black, .btn-ghost-sm, .pill, .so-option { transition: background-color 90ms linear, border-color 90ms linear, outline-color 90ms linear; }
3163
3170
  @media (prefers-reduced-motion: reduce) {
3164
3171
  *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
3165
3172
  }
@@ -3207,8 +3214,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3207
3214
  labels; the destinations stay one tap away. ── */
3208
3215
  @media (min-width: 768px) and (max-width: 1199px) {
3209
3216
  .sidebar { width: 56px; }
3210
- .nav-item { justify-content: center; padding: 0; gap: 0; }
3217
+ .nav-item, .nav-group-toggle { justify-content: center; padding: 0; gap: 0; }
3211
3218
  .nav-item > span { display: none; }
3219
+ .nav-group-toggle .nav-label { display: none; }
3212
3220
  .nav-item .count.abs { display: inline; position: absolute; top: 2px; right: 6px; }
3213
3221
  .nav-item { position: relative; }
3214
3222
  .nav-divider, .nav-views-label, .nav-views { display: none; }
@@ -3366,8 +3374,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3366
3374
  .work-warning { color: var(--absent-warn-fg); background: var(--bg-card); border: 1px solid var(--absent-warn-fg); padding: 12px; }
3367
3375
  .work-page pre { font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; margin: 12px 0; }
3368
3376
  #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; }
3369
- #tools-nav summary { padding: 12px 16px; cursor: pointer; }
3370
- @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; } }
3377
+ @media (max-width: 767px) { .sidebar { --nav-row-height: 44px; } }
3371
3378
  @media (max-width: 767px) { .work-page { padding: 12px; } .work-page button, #work-compose select { min-height: 44px; } }
3372
3379
  </style>
3373
3380
  </head>
@@ -3406,22 +3413,32 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3406
3413
  <input id="proj-search" type="text" placeholder="Filter projects…" aria-label="Filter projects" oninput="renderProjMenu(this.value)" />
3407
3414
  </div>
3408
3415
  <div id="proj-menu-list"></div>
3416
+ <div id="proj-menu-status" role="status"></div>
3409
3417
  </div>
3410
3418
  </div>
3411
3419
  <nav class="nav" id="nav-tablist" role="tablist" aria-orientation="vertical">
3412
- <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>
3420
+ <div class="nav-item active" data-tab="work" role="tab" tabindex="0" aria-selected="true" aria-label="Work" title="Work" onkeydown="navTabKeydown(event, this)" onclick="switchTab('work', this)">
3421
+ <svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="7" width="18" height="14" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v3h4v-3"/></svg>
3422
+ <span class="nav-label">Work</span>
3423
+ </div>
3413
3424
  <div class="nav-item" data-tab="decisions" role="tab" tabindex="0" aria-label="Decisions" title="Decisions" onkeydown="navTabKeydown(event, this)" onclick="switchTab('decisions', this)">
3414
3425
  <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>
3415
- <span>Decisions</span>
3426
+ <span class="nav-label">Decisions</span>
3416
3427
  <span class="count" id="nav-inbox-count">0</span>
3417
3428
  </div>
3418
- <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>
3429
+ <div class="nav-item" data-tab="history" role="tab" tabindex="0" aria-label="History" title="History" onkeydown="navTabKeydown(event, this)" onclick="switchTab('history', this)">
3430
+ <svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 10a9 9 0 1 1 2 8M3 4v6h6M12 7v5l3 2"/></svg>
3431
+ <span class="nav-label">History</span>
3432
+ </div>
3419
3433
  </nav>
3420
- <details id="tools-nav"><summary>Tools</summary>
3434
+ <details id="tools-nav"><summary class="nav-group-toggle" aria-label="Tools" title="Tools">
3435
+ <svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 6l6 6-6 6"/></svg>
3436
+ <span class="nav-label">Tools</span>
3437
+ </summary>
3421
3438
  <nav class="nav" role="tablist" aria-label="Technical tools" aria-orientation="vertical">
3422
3439
  <div class="nav-item" data-tab="ledger" role="tab" tabindex="0" aria-label="Ledger" title="Ledger" onkeydown="navTabKeydown(event, this)" onclick="switchTab('ledger', this)">
3423
3440
  <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>
3424
- <span>Ledger</span>
3441
+ <span class="nav-label">Ledger</span>
3425
3442
  <!-- Real spend total is Ledger's own content (.7); this keeps the count
3426
3443
  of agent budget caps the old Budgets tab already computed, rather
3427
3444
  than inventing a $ figure with nothing behind it yet. -->
@@ -3429,7 +3446,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3429
3446
  </div>
3430
3447
  <div class="nav-item" data-tab="fleet" role="tab" tabindex="0" aria-label="Fleet" title="Fleet" onkeydown="navTabKeydown(event, this)" onclick="switchTab('fleet', this)">
3431
3448
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><path d="M8 6h8M6 8v8M18 8v8M8 18h8"/></svg>
3432
- <span>Fleet</span>
3449
+ <span class="nav-label">Fleet</span>
3433
3450
  <!-- The absence glyph, not a count (DESIGN §5): something in Fleet is
3434
3451
  unclassified. Left hidden — the classification read lives in the
3435
3452
  Fleet screen body (.8/.9), out of this shell task's scope. -->
@@ -3437,22 +3454,18 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3437
3454
  </div>
3438
3455
  <div class="nav-item" data-tab="harness" role="tab" tabindex="0" aria-label="Harness" title="Harness" onkeydown="navTabKeydown(event, this)" onclick="switchTab('harness', this)">
3439
3456
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 12h4l2-5 3 10 2-5h7"/></svg>
3440
- <span>Harness</span>
3457
+ <span class="nav-label">Harness</span>
3441
3458
  <span class="count" id="nav-harness-count"></span>
3442
3459
  </div>
3443
3460
  <div class="nav-item" data-tab="usage" role="tab" tabindex="0" aria-label="Usage" title="Usage" onkeydown="navTabKeydown(event, this)" onclick="switchTab('usage', this)">
3444
3461
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 20V10 M10 20V4 M16 20v-7 M22 20H2"/></svg>
3445
- <span>Usage</span>
3462
+ <span class="nav-label">Usage</span>
3446
3463
  </div>
3447
3464
  </nav>
3448
3465
  </details>
3449
3466
  <div class="nav-divider"></div>
3450
3467
  <div class="nav-views-label" id="nav-views-label" hidden>Views</div>
3451
- <!-- Saved views over Fleet (DESIGN §5 point 2). Static for this shell —
3452
- filtering the fleet list is the Fleet screen body's job (.8), not this
3453
- task's; deliberately not `.nav-item`/`role="tab"`, since they neither
3454
- switch a top-level destination nor do anything yet, and a control that
3455
- looks live and isn't fails `ui-ux-pro-max` empty-nav-state. -->
3468
+ <!-- Fleet filters share the row pattern, but stay buttons, not destinations. -->
3456
3469
  <div class="nav-views" hidden>
3457
3470
  <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>
3458
3471
  <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>
@@ -4085,6 +4098,10 @@ window.GctoWork.bind({
4085
4098
  project: () => new URLSearchParams(location.search).get('project') || '',
4086
4099
  visible: () => currentTab === 'work' || currentTab === 'history',
4087
4100
  read: url => api(url),
4101
+ publish: async (url, body) => {
4102
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
4103
+ return response.json();
4104
+ },
4088
4105
  openIssue: (id, decision) => { if (decision) switchTab('decisions'); openSideById(id); },
4089
4106
  });
4090
4107
  let searchQuery = '';
@@ -5992,6 +6009,7 @@ let usageDays = 30;
5992
6009
  let usageHost = 'claude';
5993
6010
  let usageData = null;
5994
6011
  let _usageTimer = null;
6012
+ let _usageRequest = 0;
5995
6013
  const USAGE_HOSTS = { claude: 'Claude Code', codex: 'Codex' };
5996
6014
  const USAGE_LOGS = { claude: '~/.claude/projects', codex: '~/.codex/sessions' };
5997
6015
  const USAGE_FN = { chat: 'Conversations', subagent: 'Subagents', automation: 'Automations', headless: 'Headless runs' };
@@ -6014,7 +6032,13 @@ async function loadUsage(days) {
6014
6032
  if (days) usageDays = days;
6015
6033
  document.querySelectorAll('.usage-chip[data-days]').forEach((c) => c.classList.toggle('active', Number(c.dataset.days) === usageDays));
6016
6034
  clearTimeout(_usageTimer);
6035
+ const request = ++_usageRequest, requestedDays = usageDays;
6036
+ const loadingBody = document.getElementById('usage-body');
6037
+ if (loadingBody && (!usageData || usageData.window?.days !== requestedDays)) {
6038
+ loadingBody.className = 'usage-note'; loadingBody.textContent = 'Loading session statistics…';
6039
+ }
6017
6040
  const d = await api(`/api/usage?days=${usageDays}`);
6041
+ if (request !== _usageRequest || requestedDays !== usageDays || currentTab !== 'usage') return;
6018
6042
  const body = document.getElementById('usage-body');
6019
6043
  if (!body) return;
6020
6044
  if (!d || d.error) {
@@ -6035,6 +6059,12 @@ async function loadUsage(days) {
6035
6059
  }
6036
6060
  usageData = d;
6037
6061
  renderUsage(d);
6062
+ if (d.stale || d.refreshing) {
6063
+ const note = document.createElement('p'); note.className = 'usage-note';
6064
+ note.textContent = d.refreshError || 'Showing the last counted statistics while refreshing in the background.';
6065
+ body.prepend(note);
6066
+ }
6067
+ if (d.refreshing) _usageTimer = setTimeout(() => { if (currentTab === 'usage') loadUsage(); }, 2000);
6038
6068
  loadOutcomes();
6039
6069
  }
6040
6070
 
@@ -7480,8 +7510,28 @@ function toggleProjMenu(e) {
7480
7510
  const isOpen = sw.classList.toggle('open');
7481
7511
  if (isOpen) {
7482
7512
  document.getElementById('proj-search').focus();
7483
- renderProjMenu('');
7484
- }
7513
+ renderProjMenu();
7514
+ refreshProjMenu();
7515
+ }
7516
+ }
7517
+ let _projMenuRead = 0, _projMenuAbort;
7518
+ async function refreshProjMenu() {
7519
+ const request = ++_projMenuRead;
7520
+ _projMenuAbort?.abort();
7521
+ const controller = new AbortController(); _projMenuAbort = controller;
7522
+ const note = document.getElementById('proj-menu-status');
7523
+ if (note) note.textContent = 'Refreshing projects…';
7524
+ const timer = setTimeout(() => controller.abort(), 5000);
7525
+ try {
7526
+ const projects = await api('/api/projects', { signal: controller.signal });
7527
+ if (request !== _projMenuRead) return;
7528
+ if (!Array.isArray(projects) || projects.some(p => !p || typeof p.slug !== 'string')) throw Error('Invalid project list');
7529
+ allProjects = projects;
7530
+ if (note) note.textContent = '';
7531
+ if (document.getElementById('proj-switch').classList.contains('open')) renderProjMenu();
7532
+ } catch {
7533
+ if (request === _projMenuRead && note) note.textContent = 'Could not refresh projects; showing the last loaded list.';
7534
+ } finally { clearTimeout(timer); }
7485
7535
  }
7486
7536
  function closeProjMenu() {
7487
7537
  document.getElementById('proj-switch')?.classList.remove('open');
@@ -7531,14 +7581,14 @@ function archetypeIcon(arch, size = 14) {
7531
7581
  return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${meta.color}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="${meta.d}"/></svg>`;
7532
7582
  }
7533
7583
 
7534
- function renderProjMenu(filter = '') {
7584
+ function renderProjMenu(filter = document.getElementById('proj-search')?.value || '') {
7535
7585
  const list = document.getElementById('proj-menu-list');
7536
7586
  if (!list) return;
7537
7587
  if (!allProjects.length) {
7538
7588
  list.innerHTML = '<div class="proj-menu-empty">No projects registered yet</div>';
7539
7589
  return;
7540
7590
  }
7541
- const f = filter.toLowerCase();
7591
+ const f = filter.trim().toLowerCase();
7542
7592
  const filtered = allProjects.filter(p =>
7543
7593
  !f || (p.slug || '').toLowerCase().includes(f) ||
7544
7594
  (p.archetype || '').toLowerCase().includes(f) ||
@@ -9078,7 +9128,9 @@ function switchTab(id, el) {
9078
9128
  closeMenu(false); // choosing a destination is the end of using the menu
9079
9129
  // sidebar nav
9080
9130
  document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
9081
- t.classList.toggle('active', t.dataset.tab === navKey);
9131
+ const selected = t.dataset.tab === navKey;
9132
+ t.classList.toggle('active', selected);
9133
+ t.setAttribute('aria-selected', String(selected));
9082
9134
  });
9083
9135
  // top tabs
9084
9136
  document.querySelectorAll('.tab-btn').forEach(t => t.classList.toggle('active', t.dataset.tab === id));
@@ -9389,8 +9441,11 @@ function renderFleetViews() {
9389
9441
  const el = document.getElementById(`nav-view-${key}`);
9390
9442
  if (!el) continue;
9391
9443
  const n = agents.filter((a) => inFleetView(a, key)).length;
9392
- el.classList.toggle('active', fleetState.view === key);
9393
- el.innerHTML = `<span>${esc(label)}</span><span class="nav-view-count">${n}</span>`;
9444
+ const selected = fleetState.view === key;
9445
+ el.classList.toggle('active', selected);
9446
+ el.setAttribute('aria-pressed', String(selected));
9447
+ // A filter glyph keeps the same icon/label columns as the destinations.
9448
+ el.innerHTML = `<svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 5h16l-6 7v6l-4 2v-8z"/></svg><span class="nav-label">${esc(label)}</span><span class="nav-view-count">${n}</span>`;
9394
9449
  }
9395
9450
  const abs = document.getElementById('nav-fleet-abs');
9396
9451
  if (abs) abs.hidden = !agents.some((a) => a.posture?.state === 'unclassified' || a.posture?.state === 'unreadable');
@@ -24,8 +24,9 @@ import { fileURLToPath } from 'node:url';
24
24
 
25
25
  import { classify } from './change-tier.mjs';
26
26
  import { selectJudgeModel } from './judge-model.mjs';
27
- // effectiveGates lives in the built CLI package (TS → dist).
28
- import { effectiveGates } from '../../packages/cli/dist/archetypes.js';
27
+ // Generated from the canonical CLI policy, but shipped in Git too: marketplace
28
+ // installs do not contain the gitignored CLI build.
29
+ import { effectiveGates } from './gate-policy.mjs';
29
30
 
30
31
  /** Parse archetype + project_size out of a .great_cto/PROJECT.md body. */
31
32
  export function parseProject(text) {
@@ -0,0 +1,97 @@
1
+ // Generated by scripts/build-gate-policy.mjs from packages/cli/src/archetypes.ts.
2
+ // Do not edit; regenerate, then run --check. No CLI build is needed at runtime.
3
+ export const GATES_BY_ARCHETYPE = {
4
+ "web-service": ["plan", "qa", "ship"],
5
+ "mobile-app": ["plan", "qa", "ship"],
6
+ "ai-system": ["plan", "cost", "qa", "security", "ship"],
7
+ "agent-product": ["plan", "cost", "qa", "security", "ship"],
8
+ "mlops": ["plan", "cost", "qa", "security", "ship"],
9
+ "data-platform": ["plan", "qa", "ship"],
10
+ "streaming": ["plan", "qa", "ship"],
11
+ "infra": ["plan", "qa", "ship"],
12
+ "library": ["plan", "qa", "ship"],
13
+ "cli-tool": ["plan", "qa", "ship"],
14
+ "commerce": ["plan", "qa", "security", "ship", "compliance"],
15
+ "marketplace": ["plan", "qa", "security", "ship", "compliance"],
16
+ "fintech": ["plan", "qa", "security", "ship", "compliance"],
17
+ "healthcare": ["plan", "qa", "security", "ship", "compliance"],
18
+ "web3": ["plan", "qa", "oracle-review", "security", "ship"],
19
+ "iot-embedded": ["plan", "qa", "security", "ship"],
20
+ "regulated": ["plan", "qa", "security", "ship", "compliance"],
21
+ "devtools": ["plan", "qa", "ship"],
22
+ "browser-extension": ["plan", "qa", "ship"],
23
+ "game": ["plan", "qa", "ship"],
24
+ "cms": ["plan", "qa", "ship"],
25
+ "enterprise-saas": ["plan", "qa", "security", "ship", "compliance"],
26
+ "edtech": ["plan", "qa", "edtech-review", "security", "ship", "compliance"],
27
+ "gov-public": ["plan", "qa", "gov-review", "security", "ship", "compliance"],
28
+ "insurance": ["plan", "qa", "insurance-review", "security", "ship", "compliance"],
29
+ "legal": ["plan", "qa", "upl-review", "security", "ship", "compliance"],
30
+ "defense-govcon": ["plan", "qa", "cmmc-assessment", "security", "ship", "compliance"],
31
+ // Product Builder archetypes (A1–A6) — non-regulated baseline; the single CTO gate
32
+ // emerges at change_tier T1 (effectiveGates → [plan]).
33
+ "vertical-saas": ["plan", "qa", "ship"],
34
+ "booking": ["plan", "qa", "ship"],
35
+ "crm": ["plan", "qa", "ship"],
36
+ "dashboard": ["plan", "qa", "ship"],
37
+ "content-platform": ["plan", "qa", "ship"],
38
+ "marketplace-lite": ["plan", "qa", "ship"],
39
+ "greenfield": ["plan"],
40
+ };
41
+ /**
42
+ * Returns the subset of gates the pipeline will actually open for a
43
+ * given archetype + project_size. Used by the orchestrator before
44
+ * opening any gate to skip unnecessary human checkpoints on smaller
45
+ * projects (e.g. nano always skips QA).
46
+ */
47
+ export function gatesFor(archetype, size) {
48
+ const all = GATES_BY_ARCHETYPE[archetype] ?? [];
49
+ if (size === "nano")
50
+ return all.filter((g) => g === "plan");
51
+ if (size === "small")
52
+ return all.filter((g) => g === "plan" || g === "ship");
53
+ if (size === "medium")
54
+ return all;
55
+ // large + enterprise → ensure compliance is included
56
+ return all.includes("compliance") ? all : [...all, "compliance"];
57
+ }
58
+ /** Gates that form the non-negotiable floor for a *regulated* archetype. */
59
+ const FLOOR_GATES = ["security", "compliance", "ship"];
60
+ /**
61
+ * The human gates the pipeline opens for a change, given its archetype, the
62
+ * project size, AND the per-change risk tier. Composes with `gatesFor()`:
63
+ *
64
+ * - T2 (default): the full size baseline, with `ship` guaranteed even if a
65
+ * small project_size had stripped it. An irreversible change always gets a
66
+ * final go/no-go. Nothing is ever removed → fail-safe for an unknown tier.
67
+ * - T1: only `plan` (you review intent) PLUS the regulated floor. The standalone
68
+ * qa/security-as-ceremony is dropped because CI + tests cover a reversible change.
69
+ * - T0: the regulated floor only — for a plain archetype that is the empty set
70
+ * (CI is the gate); for a regulated one, security+compliance+ship still fire.
71
+ *
72
+ * An archetype is "regulated" iff its baseline contains `security` or `compliance`.
73
+ * That is what keeps the downgrade sound: a fintech repo cannot ship an
74
+ * irreversible change unreviewed even when the change is labelled a fix.
75
+ *
76
+ * NOTE: change-specific T2 triggers (a migration, a new write-connector, a prod
77
+ * deploy) are resolved upstream by the classifier, which escalates such a change
78
+ * to T2 before this function sees it. Here we only map an already-decided tier.
79
+ */
80
+ export function effectiveGates(archetype, size, tier = "T2") {
81
+ const base = gatesFor(archetype, size);
82
+ // T2 (and any unrecognized tier) → full baseline, never downgraded, ship forced.
83
+ if (tier !== "T0" && tier !== "T1") {
84
+ return base.includes("ship") ? base : [...base, "ship"];
85
+ }
86
+ // Regulated archetypes carry a floor that survives every downgrade.
87
+ const isRegulated = base.includes("security") || base.includes("compliance");
88
+ const floor = isRegulated ? base.filter((g) => FLOOR_GATES.includes(g)) : [];
89
+ if (tier === "T1") {
90
+ const keep = new Set(floor);
91
+ if (base.includes("plan"))
92
+ keep.add("plan");
93
+ return base.filter((g) => keep.has(g)); // preserve base order
94
+ }
95
+ // T0 — maintenance.
96
+ return floor;
97
+ }
@@ -0,0 +1,169 @@
1
+ /** Publish an explicitly approved, already-committed verified task. Never merge. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { readWorkTask, mutateWorkTask, acquireProjectLease, publicWorkTask } from './work-tasks.mjs';
5
+ import { treeReceipt } from './receipt.mjs';
6
+ import { scan } from './secret-patterns.mjs';
7
+ import { realpathSync } from 'node:fs';
8
+
9
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
10
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
11
+ const bindingOf = (value, revision = value.revision) => ({ taskId: value.taskId, revision,
12
+ repository: value.repository, origin: value.origin, branch: value.branch, base: value.base,
13
+ head: value.head, baseHead: value.baseHead, tree: value.tree, paths: value.paths,
14
+ allow: value.allow, patchDigest: value.patchDigest });
15
+ const ref = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9/_-]{0,120}$/.test(value)
16
+ && !value.endsWith('/') && !value.includes('//');
17
+ const scopePath = value => typeof value === 'string' && value.length <= 300 && value.length > 0
18
+ && !value.startsWith('/') && !value.startsWith('-') && !value.includes('\\') && !value.includes(':') && !value.includes(',')
19
+ && !/[\x00-\x1f]/.test(value) && !value.split('/').some(p => !p || p === '.' || p === '..');
20
+ function command(bin, args, root, execute = execFileSync) {
21
+ try { return String(execute(bin, args, { cwd: root, encoding: 'utf8', timeout: 20000,
22
+ maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
23
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GH_PROMPT_DISABLED: '1' } })); }
24
+ catch { throw Error(`${bin} ${args[0]} failed; inspect local authentication, hooks or network (output withheld)`); }
25
+ }
26
+ function repository(url, root, execute) {
27
+ const https = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(url);
28
+ if (https) return `${https[1]}/${https[2]}`;
29
+ const ssh = /^git@([A-Za-z0-9_.-]+):([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(url);
30
+ if (!ssh || ssh[1].startsWith('-')) throw Error('publication requires a credential-free GitHub origin');
31
+ if (ssh[1] !== 'github.com') {
32
+ // Inspect OpenSSH's effective config without connecting; never echo identity paths.
33
+ const config = command('ssh', ['-G', '-o', 'CanonicalizeHostname=no', '-o', 'BatchMode=yes', `git@${ssh[1]}`], root, execute);
34
+ const values = new Map(config.split('\n').map(line => line.trim().split(/\s+/, 2)));
35
+ if (values.get('hostname')?.toLowerCase() !== 'github.com' || values.get('user') !== 'git' || values.get('port') !== '22')
36
+ throw Error('SSH alias does not resolve to git@github.com:22');
37
+ if (process.env.GIT_SSH || process.env.GIT_SSH_COMMAND || /(?:^|\0)(?:core\.sshcommand|ssh\.variant)\n/i.test(command('git', ['config', '--list', '--null'], root, execute)))
38
+ throw Error('custom SSH transport cannot be verified for publication');
39
+ }
40
+ return `${ssh[2]}/${ssh[3]}`;
41
+ }
42
+ function ready(task, root) {
43
+ if (task.managed === false || task.intent === 'research' || task.phase !== 'verified' || task.outcome?.state !== 'verified')
44
+ throw Error('publication requires a managed verified delivery task');
45
+ if (task.decisions?.length || task.operations.some(o => o.state === 'running')) throw Error('task has pending decisions or an active host operation');
46
+ const receipt = treeReceipt(root);
47
+ if (!receipt || receipt.dirty || receipt.truncated || !same(receipt, task.outcome.receipt)) throw Error('verified tree receipt is missing or stale; commit and reverify before publication');
48
+ }
49
+
50
+ export function previewPublication({ root, taskId, base = 'main', allow = null }, options = {}) {
51
+ const task = readWorkTask(taskId, { ...options, root }); ready(task, root);
52
+ const git = args => command('git', args, root, options.execute);
53
+ if (realpathSync(git(['rev-parse', '--show-toplevel']).trim()) !== realpathSync(root)) throw Error('publication task must own the repository root, not a nested directory');
54
+ // Even metadata/log changes excluded from verification cannot accidentally ship.
55
+ if (git(['status', '--porcelain=v1', '--untracked-files=all']).trim()) throw Error('publication requires a fully clean worktree and index');
56
+ const branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD']).trim();
57
+ if (!ref(base) || !ref(branch) || branch === base || ['main', 'master', 'develop'].includes(branch)) throw Error('publication requires a non-protected named feature branch and valid base');
58
+ git(['check-ref-format', `refs/heads/${base}`]); git(['check-ref-format', `refs/heads/${branch}`]);
59
+ const origins = git(['remote', 'get-url', '--all', 'origin']).trim().split('\n');
60
+ const pushOrigins = git(['remote', 'get-url', '--push', '--all', 'origin']).trim().split('\n');
61
+ if (origins.length !== 1 || pushOrigins.length !== 1 || origins[0] !== pushOrigins[0]) throw Error('origin must have exactly one identical fetch and push URL');
62
+ const repo = repository(origins[0], root, options.execute);
63
+ const head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim();
64
+ const baseHead = git(['rev-parse', '--verify', `refs/remotes/origin/${base}^{commit}`]).trim();
65
+ git(['merge-base', '--is-ancestor', baseHead, head]);
66
+ const commits = git(['rev-list', '--reverse', `${baseHead}..${head}`]).trim().split('\n').filter(Boolean);
67
+ if (!commits.length || commits.length > 100 || git(['rev-list', '--merges', `${baseHead}..${head}`]).trim()) throw Error('publication supports 1..100 linear commits; merge commits require manual review');
68
+ const paths = [...new Set(commits.flatMap(commit => git(['diff-tree', '-r', '--no-commit-id', '--no-renames', '--name-only', '-z', commit]).split('\0').filter(Boolean)))];
69
+ if (!paths.length || paths.length > 200) throw Error('publication requires 1..200 changed files');
70
+ const allowed = allow || task.authority?.writeScope;
71
+ if (!Array.isArray(allowed) || !allowed.length || allowed.some(p => !scopePath(p))) throw Error('publication requires explicit relative path scope');
72
+ if (paths.some(p => !scopePath(p) || !allowed.some(a => p === a || p.startsWith(a + '/')))) throw Error('diff contains paths outside the approved publication scope');
73
+ const patch = commits.map(commit => git(['show', '--no-color', '--format=fuller', '--no-ext-diff', '--no-textconv', '--no-renames', '--binary', commit, '--'])).join('\n');
74
+ if (Buffer.byteLength(patch) > 2 * 1024 * 1024 || patch.includes('GIT binary patch')) throw Error('publication preview exceeds its text-only size limit; inspect manually');
75
+ // Scan complete publication content, including removed lines; never return matched secrets.
76
+ if (scan(patch).length) throw Error('publication diff contains a possible secret; resolve locally before preview');
77
+ const binding = { taskId, revision: task.revision, repository: repo, origin: origins[0], branch, base,
78
+ head, baseHead, tree: git(['rev-parse', 'HEAD^{tree}']).trim(), paths, allow: [...allowed], patchDigest: digest(patch) };
79
+ let confirmation = { expectedRevision: task.revision, approval: digest(binding) };
80
+ const pending = task.publication;
81
+ if (pending && pending.state !== 'pr-linked') {
82
+ if (task.revision !== pending.guardRevision || digest(bindingOf(binding, pending.approvedRevision)) !== pending.approval
83
+ || digest(bindingOf(pending, pending.approvedRevision)) !== pending.approval) throw Error('unfinished publication changed; reconcile before a new preview');
84
+ confirmation = { expectedRevision: pending.approvedRevision, approval: pending.approval };
85
+ }
86
+ return { ...binding, approval: digest(binding), confirmation, patch,
87
+ summary: git(['diff', '--no-ext-diff', '--no-textconv', '--stat', baseHead, head, '--']),
88
+ scope: 'push approved commit and create/find draft PR only; no commit, merge, release or deploy' };
89
+ }
90
+
91
+ function assertPR(pr, preview) {
92
+ if (!pr || pr.headRefOid !== preview.head || pr.headRefName !== preview.branch || pr.baseRefName !== preview.base
93
+ || pr.state !== 'OPEN' || pr.isDraft !== true || typeof pr.url !== 'string'
94
+ || !pr.url.startsWith(`https://github.com/${preview.repository}/pull/`) || !/\/pull\/[1-9][0-9]*$/.test(pr.url))
95
+ throw Error('existing PR does not match approved SHA/base/draft state; inspect it manually');
96
+ return pr;
97
+ }
98
+
99
+ export function publishPublication({ root, taskId, base = 'main', allow = null, expectedRevision, approval, confirm }, options = {}) {
100
+ if (confirm !== 'publish-draft-pr' || !Number.isInteger(expectedRevision) || !/^[0-9a-f]{64}$/.test(approval || ''))
101
+ throw Error('publication requires explicit publish-draft-pr confirmation, revision and preview approval');
102
+ const lease = acquireProjectLease(root, options);
103
+ let active = false;
104
+ try {
105
+ let task = readWorkTask(taskId, { ...options, root }), existing = task.publication;
106
+ if (existing && existing.approval === approval && existing.approvedRevision === expectedRevision) {
107
+ if (base !== existing.base || (allow && !same(allow, existing.allow))) throw Error('publication retry parameters conflict with approved request');
108
+ if (existing.state === 'pr-linked') return { publication: existing, task: publicWorkTask(task) };
109
+ if (task.revision !== existing.guardRevision) throw Error('task changed after publication approval; new preview required');
110
+ } else {
111
+ const preview = previewPublication({ root, taskId, base, allow }, options);
112
+ if (preview.revision !== expectedRevision || preview.approval !== approval) throw Error('stale publication preview; refresh and approve exact content');
113
+ if (existing && existing.state !== 'pr-linked') throw Error('unfinished publication exists; reconcile its approved operation first');
114
+ task = mutateWorkTask(taskId, t => { t.publication = { ...preview, patch: undefined, summary: undefined,
115
+ approvedRevision: expectedRevision, guardRevision: t.revision + 1, state: 'prepared', url: null, lastError: null }; }, { ...options, root });
116
+ existing = task.publication;
117
+ }
118
+ active = true;
119
+ // Revalidate task evidence/content on retry without issuing a new approval.
120
+ const current = previewPublication({ root, taskId, base: existing.base, allow: existing.allow }, options);
121
+ if (digest(bindingOf(current, existing.approvedRevision)) !== approval
122
+ || digest(bindingOf(existing, existing.approvedRevision)) !== approval) throw Error('publication inputs changed; refuse external writes');
123
+ const git = args => command('git', args, root, options.execute);
124
+ const gh = args => command('gh', args, root, options.execute);
125
+ const checkpoint = (state, more = {}) => {
126
+ task = mutateWorkTask(taskId, t => {
127
+ if (t.revision !== t.publication.guardRevision) throw Error('task revision changed during publication');
128
+ Object.assign(t.publication, more, { state, guardRevision: t.revision + 1, lastError: null });
129
+ }, { ...options, root }); existing = task.publication;
130
+ };
131
+ const remoteBase = git(['ls-remote', '--refs', 'origin', `refs/heads/${existing.base}`]).trim().split(/\s+/)[0];
132
+ if (remoteBase !== existing.baseHead) throw Error('remote base moved; fetch and reverify before publication');
133
+ const remoteHead = git(['ls-remote', '--refs', 'origin', `refs/heads/${existing.branch}`]).trim().split(/\s+/)[0];
134
+ if (remoteHead && remoteHead !== existing.head) throw Error('remote feature branch differs; no overwrite or force push permitted');
135
+ // Empty expected ref is a create-only CAS, not permission to overwrite a ref.
136
+ // Without it, another writer could create an ancestor between ls-remote and push.
137
+ // Immutable SHA refspec; normal hooks remain enabled. No --force or nonempty lease.
138
+ if (remoteHead !== existing.head) git(['push', `--force-with-lease=refs/heads/${existing.branch}:`, 'origin', `${existing.head}:refs/heads/${existing.branch}`]);
139
+ if (git(['ls-remote', '--refs', 'origin', `refs/heads/${existing.branch}`]).trim().split(/\s+/)[0] !== existing.head)
140
+ throw Error('remote commit identity is unconfirmed; reconcile before PR creation');
141
+ checkpoint('pushed');
142
+ const list = () => {
143
+ const prs = JSON.parse(gh(['pr', 'list', '--repo', existing.repository, '--head', existing.branch, '--base', existing.base,
144
+ '--state', 'all', '--limit', '100', '--json', 'url,headRefOid,headRefName,baseRefName,state,isDraft']));
145
+ if (!Array.isArray(prs) || prs.length > 1) throw Error('PR identity is ambiguous');
146
+ return prs[0] ? assertPR(prs[0], existing) : null;
147
+ };
148
+ let pr = list();
149
+ if (!pr) {
150
+ // Persist intent BEFORE sending create. A timeout is an uncertain outcome, not permission to create twice.
151
+ if (existing.createIssued) throw Error('PR creation outcome is uncertain; no duplicate create will be sent; reconcile remotely');
152
+ if (git(['ls-remote', '--refs', 'origin', `refs/heads/${existing.base}`]).trim().split(/\s+/)[0] !== existing.baseHead)
153
+ throw Error('remote base changed before PR creation; reverify before publication');
154
+ checkpoint('pushed', { createIssued: true });
155
+ gh(['pr', 'create', '--repo', existing.repository, '--base', existing.base, '--head', existing.branch, '--draft',
156
+ '--title', `Task ${taskId}`, '--body', `Verified task publication. Task: ${taskId}. Approved commit: ${existing.head}. Merge, release and deploy require separate authorization.`]);
157
+ pr = list();
158
+ if (!pr) throw Error('created PR is not visible yet; retry reconciliation only');
159
+ }
160
+ checkpoint('pr-linked', { url: pr.url });
161
+ return { publication: existing, task: publicWorkTask(task) };
162
+ } catch (error) {
163
+ if (active) mutateWorkTask(taskId, t => {
164
+ if (t.revision !== t.publication.guardRevision) return false;
165
+ t.publication.lastError = error.message; t.publication.guardRevision = t.revision + 1;
166
+ }, { ...options, root });
167
+ throw error;
168
+ } finally { lease.release(); }
169
+ }
@@ -34,6 +34,11 @@ function validate(t) {
34
34
  if (t.intent !== undefined && !['delivery', 'research'].includes(t.intent)) throw Error('invalid task intent');
35
35
  if (t.decisions !== undefined && (!Array.isArray(t.decisions) || t.decisions.some(d => !d || !/^[0-9a-f]{64}$/.test(d.decisionId) || !['codex', 'claude-code'].includes(d.engine)))) throw Error('invalid task decisions');
36
36
  if (t.outcome && (!['research', 'delivery'].includes(t.outcome.kind) || !['verified', 'completed'].includes(t.outcome.state) || !Array.isArray(t.outcome.artifacts) || !Array.isArray(t.outcome.criteria))) throw Error('invalid task outcome');
37
+ if (t.publication && (!['prepared', 'pushed', 'pr-linked'].includes(t.publication.state)
38
+ || !/^[0-9a-f]{64}$/.test(t.publication.approval || '') || t.publication.taskId !== t.taskId
39
+ || !Number.isInteger(t.publication.approvedRevision) || !Number.isInteger(t.publication.guardRevision)
40
+ || typeof t.publication.repository !== 'string' || typeof t.publication.head !== 'string'
41
+ || !Array.isArray(t.publication.paths) || !Array.isArray(t.publication.allow))) throw Error('invalid publication state');
37
42
  return t;
38
43
  }
39
44
  export function readWorkTask(id, { store = workTaskStore(), root = null } = {}) {
@@ -257,10 +262,16 @@ export function observeWorkSession(payload, { store = workTaskStore() } = {}) {
257
262
  kind: event === 'Notification' ? 'native_permission_or_input' : 'native_session' }); }, { store, root: payload.cwd });
258
263
  }
259
264
  export function publicWorkTask(t) {
265
+ const quote = value => "'" + String(value).replace(/'/g, "'\\''") + "'";
266
+ const publication = t.publication;
260
267
  return { schemaVersion: 1, taskId: t.taskId, projectId: t.projectId, managed: t.managed, host: t.host, goal: t.goal,
261
268
  acceptance: t.acceptance, authority: t.authority, intent: t.intent || 'delivery', budget: t.budget || null, stage: t.stage || null,
262
269
  decisions: (t.decisions || []).map(({ receipt, ...d }) => ({ ...d, ...(['pipeline_gate', 'gate', 'release'].includes(d.kind) ? { bindingState: !receipt ? (d.bindingState || 'unverifiable') : JSON.stringify(treeReceipt(t.root)) === JSON.stringify(receipt) ? 'current' : 'stale' } : {}) })), outcome: t.outcome ? { source: t.outcome.source, kind: t.outcome.kind, state: t.outcome.state, verifiedAt: t.outcome.verifiedAt, completedAt: t.outcome.completedAt || null, artifacts: t.outcome.artifacts, criteria: t.outcome.criteria } : null, rework: t.rework || null, revision: t.revision, phase: t.phase, reason: t.reason,
263
270
  createdAt: t.createdAt, updatedAt: t.updatedAt, links: t.links, evidence: t.evidence, metrics: t.metrics,
271
+ publication: t.publication ? { state: t.publication.state, repository: t.publication.repository, branch: t.publication.branch,
272
+ base: t.publication.base, head: t.publication.head, url: t.publication.url, lastError: t.publication.lastError,
273
+ // A content digest is not a bearer credential. Running this command explicitly authorizes only the original operation.
274
+ resumeCommand: publication.state !== 'pr-linked' ? `great-cto task work publish --task ${t.taskId} --revision ${publication.approvedRevision} --approval ${publication.approval} --base ${quote(publication.base)} --allow ${quote(publication.allow.join(','))} --confirm publish-draft-pr` : null } : null,
264
275
  operations: t.operations.map(({ operationId, kind, state, startedAt, finishedAt, exitCode, resume }) => ({ operationId, kind, state, startedAt, finishedAt, exitCode, ...(resume ? { resume } : {}) })) };
265
276
  }
266
277
 
@@ -5,22 +5,27 @@ import { listWorkTasks, readWorkTask } from './lib/work-tasks.mjs';
5
5
  import { approveWorkDecision, decisionCapabilities } from './lib/work-decisions.mjs';
6
6
  import { applyOutcome, readOutcomeEvidence } from './lib/work-outcomes.mjs';
7
7
  import { measureWork, compareWork } from './lib/work-metrics.mjs';
8
+ import { previewPublication, publishPublication } from './lib/work-publication.mjs';
8
9
  const args = process.argv.slice(2), action = args.shift();
9
10
  try {
10
- const supported = new Set(['--dir','--task','--decision','--revision','--operation','--evidence','--baseline']);
11
+ const supported = new Set(['--dir','--task','--decision','--revision','--operation','--evidence','--baseline','--base','--allow','--approval','--confirm']);
11
12
  const opts = {};
12
13
  for (let i = 0; i < args.length; i += 2) { if (!supported.has(args[i]) || !args[i+1] || args[i+1].startsWith('--') || opts[args[i]] !== undefined) throw Error('invalid or duplicate operator option'); opts[args[i]] = args[i+1]; }
13
- const perAction = { metrics: ['--dir','--baseline'], decisions: ['--dir','--task'], approve: ['--dir','--task','--decision','--revision','--operation'], verify: ['--dir','--task','--revision','--operation','--evidence'], complete: ['--dir','--task','--revision','--operation'] };
14
+ const perAction = { preview: ['--dir','--task','--base','--allow'], publish: ['--dir','--task','--base','--allow','--revision','--approval','--confirm'], metrics: ['--dir','--baseline'], decisions: ['--dir','--task'], approve: ['--dir','--task','--decision','--revision','--operation'], verify: ['--dir','--task','--revision','--operation','--evidence'], complete: ['--dir','--task','--revision','--operation'] };
14
15
  if (perAction[action] && Object.keys(opts).some(k => !perAction[action].includes(k))) throw Error('option is not supported for this operation');
15
16
  const root = resolve(opts['--dir'] || '.'), taskId = opts['--task'];
16
17
  let result;
17
- if (action === 'metrics') {
18
+ if (['preview','publish'].includes(action)) {
19
+ const request = { root, taskId, base: opts['--base'] || 'main', allow: opts['--allow'] ? opts['--allow'].split(',') : null };
20
+ result = action === 'preview' ? previewPublication(request) : publishPublication({ ...request,
21
+ expectedRevision: Number(opts['--revision']), approval: opts['--approval'], confirm: opts['--confirm'] });
22
+ } else if (action === 'metrics') {
18
23
  const listing = listWorkTasks(root); if (listing.state === 'degraded') throw Error('task source is degraded; measurements refused');
19
24
  const current = measureWork(listing.tasks), baseline = opts['--baseline'] ? readOutcomeEvidence(root, opts['--baseline']).document : null;
20
25
  result = { ...current, comparison: compareWork(current, baseline) };
21
26
  } else if (action === 'decisions') result = decisionCapabilities(readWorkTask(taskId, { root }));
22
27
  else if (action === 'approve') result = approveWorkDecision({ root, taskId, decisionId: opts['--decision'], expectedRevision: Number(opts['--revision']), operationId: opts['--operation'] });
23
28
  else if (['verify','complete'].includes(action)) result = applyOutcome({ root, taskId, kind: action, expectedRevision: Number(opts['--revision']), operationId: opts['--operation'], evidenceFile: opts['--evidence'] });
24
- else throw Error('usage: great-cto task work decisions|approve|verify|complete|metrics --dir PATH [--task UUID --revision N --operation UUID --decision ID --evidence EXTERNAL_FILE --baseline EXTERNAL_FILE]');
29
+ else throw Error('usage: great-cto task work decisions|approve|verify|complete|metrics|preview|publish --dir PATH [--task UUID --revision N --operation UUID --decision ID --evidence EXTERNAL_FILE --baseline EXTERNAL_FILE --base BRANCH --allow PATHS --approval DIGEST --confirm publish-draft-pr]');
25
30
  console.log(JSON.stringify(result, null, 2));
26
31
  } catch (e) { console.error('work-task: ' + e.message); process.exitCode = 2; }
package/dist/main.js CHANGED
@@ -767,7 +767,7 @@ ${bold("Usage:")}
767
767
  npx great-cto ci [path] [--no-archetype] [--no-budget]
768
768
  npx great-cto mcp [--sse --port N]
769
769
  npx great-cto adapt [--dry-run]
770
- npx great-cto task work decisions|approve|verify|complete|metrics ...
770
+ npx great-cto task work decisions|approve|verify|complete|metrics|preview|publish ...
771
771
  npx great-cto codex-host doctor|list|start|resume|status ...
772
772
  npx great-cto serve [--port 3142]
773
773
  npx great-cto upgrade [superpowers|beads] Re-clone companions to latest tag + re-apply overlays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.58.2",
3
+ "version": "3.59.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",
package/postinstall.mjs CHANGED
@@ -138,32 +138,38 @@ function main() {
138
138
  * - never block. The board takes the better part of a minute to become
139
139
  * responsive; the installer does not wait for it. Detached, unref'd, output
140
140
  * discarded.
141
- * - never in CI, and never when asked not to. Both already guard the notice
142
- * above; `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
141
+ * - never in CI or a Node test runner, and never when asked not to. A test
142
+ * HOME isolates files, NOT ports: a detached board would still claim the
143
+ * user's :3141 and serve fixture projects after the test exits.
144
+ * `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
143
145
  * - never twice. `ensure` probes the port first and adopts a board that is
144
146
  * already answering, whoever started it.
145
147
  */
146
- function ensureBoard() {
147
- if (process.env.GREAT_CTO_NO_BOARD) return;
148
+ export function ensureBoard({ env = process.env, spawnFn = spawn,
149
+ write = (text) => process.stdout.write(text) } = {}) {
150
+ if (env.CI || env.GREAT_CTO_QUIET_POSTINSTALL || env.GREAT_CTO_NO_BOARD || env.NODE_TEST_CONTEXT) return false;
148
151
  try {
149
152
  const here = dirname(fileURLToPath(import.meta.url));
150
153
  const entry = join(here, 'index.mjs');
151
- if (!existsSync(entry)) return;
154
+ if (!existsSync(entry)) return false;
152
155
 
153
- const child = spawn(process.execPath, [entry, 'board', 'ensure'], {
156
+ const child = spawnFn(process.execPath, [entry, 'board', 'ensure'], {
154
157
  detached: true,
155
158
  stdio: 'ignore',
159
+ env,
156
160
  });
157
161
  child.unref();
158
- process.stdout.write(' great-cto: starting the board — http://localhost:3141\n');
162
+ write(' great-cto: starting the board — http://localhost:3141\n');
163
+ return true;
159
164
  } catch (e) {
160
165
  // Never fail the install — but never fail SILENTLY either. The first version
161
166
  // of this function referenced an import that was not there; the catch ate the
162
167
  // ReferenceError and the hook printed nothing, so the board simply did not
163
168
  // start and nothing said why. A swallowed error is the defect this project
164
169
  // spends most of its checks on.
165
- process.stdout.write(` great-cto: could not start the board — ${e?.message || e}\n`);
166
- process.stdout.write(' Start it yourself with: great-cto board\n');
170
+ write(` great-cto: could not start the board — ${e?.message || e}\n`);
171
+ write(' Start it yourself with: great-cto board\n');
172
+ return false;
167
173
  }
168
174
  }
169
175