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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/publication-tickets.mjs +26 -0
- package/board/packages/board/lib/routes.mjs +74 -6
- package/board/packages/board/lib/task-inspector.mjs +21 -0
- package/board/packages/board/lib/usage-report-worker.mjs +9 -0
- package/board/packages/board/lib/usage-report.mjs +45 -0
- package/board/packages/board/lib/work.mjs +16 -2
- package/board/packages/board/public/assets/work-view.js +72 -5
- package/board/packages/board/public/index.html +93 -38
- package/board/scripts/lib/gate-plan.mjs +3 -2
- package/board/scripts/lib/gate-policy.mjs +97 -0
- package/board/scripts/lib/work-publication.mjs +169 -0
- package/board/scripts/lib/work-tasks.mjs +11 -0
- package/board/scripts/work-task.mjs +9 -4
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/postinstall.mjs +15 -9
|
@@ -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.
|
|
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 {
|
|
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 !== '
|
|
1583
|
+
if (snap.state !== 'counted') {
|
|
1516
1584
|
body = { state: snap.state, why: snap.why };
|
|
1517
1585
|
} else {
|
|
1518
|
-
const sum =
|
|
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 =
|
|
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
|
-
+
|
|
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
|
-
|
|
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]'))
|
|
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
|
-
.
|
|
565
|
-
.nav-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
.
|
|
586
|
-
.nav-item.active .icon { color: var(--accent-text); }
|
|
587
|
-
.nav-
|
|
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
|
-
|
|
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)"
|
|
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)"
|
|
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
|
|
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
|
-
<!--
|
|
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
|
-
|
|
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
|
-
|
|
9393
|
-
el.
|
|
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
|
-
//
|
|
28
|
-
|
|
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 (
|
|
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
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.
|
|
142
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|