great-cto 3.46.2 → 3.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/board/.claude-plugin/plugin.json +6 -1
- package/board/packages/board/lib/routes.mjs +19 -0
- package/board/packages/board/lib/work.mjs +136 -0
- package/board/packages/board/public/assets/work-view.js +97 -0
- package/board/packages/board/public/index.html +74 -21
- package/board/scripts/codex-pipeline.mjs +70 -9
- package/board/scripts/lib/claude-exec.mjs +68 -0
- package/board/scripts/lib/codex-host-state.mjs +8 -3
- package/board/scripts/lib/codex-pipeline.mjs +215 -32
- package/board/scripts/lib/session-status.mjs +2 -0
- package/board/scripts/lib/work-decisions.mjs +33 -0
- package/board/scripts/lib/work-metrics.mjs +31 -0
- package/board/scripts/lib/work-outcomes.mjs +78 -0
- package/board/scripts/lib/work-tasks.mjs +302 -0
- package/board/scripts/work-task.mjs +26 -0
- package/dist/codex-host.js +1 -1
- package/dist/main.js +44 -3
- package/dist/run.js +321 -0
- package/dist/work-task.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Host-owned approval adapter. Ordinary resume never approves a proposal. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readWorkTask, beginWork, finishWork, publicWorkTask } from './work-tasks.mjs';
|
|
5
|
+
export function decisionCapabilities(task) {
|
|
6
|
+
const busy = task.operations.some(o => o.state === 'running');
|
|
7
|
+
return publicWorkTask(task).decisions.map(d => ({ ...d, capability: {
|
|
8
|
+
action: d.engine === 'codex' ? 'approve' : 'native_host',
|
|
9
|
+
enabled: !busy && !['stale', 'unverifiable'].includes(d.bindingState) && d.engine === 'codex' && task.managed !== false,
|
|
10
|
+
reason: d.bindingState === 'stale' ? 'Proposal receipt is stale; refresh it inside its host' : busy ? 'Operation owns this task' : d.engine === 'claude-code' ? 'Native permission and pipeline decisions stay inside the bound Claude session' : null,
|
|
11
|
+
} }));
|
|
12
|
+
}
|
|
13
|
+
export function approveWorkDecision({ root, taskId, decisionId, expectedRevision, operationId }, { execute = spawnSync, ...options } = {}) {
|
|
14
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 1 || !/^[0-9a-f]{64}$/.test(decisionId || '')) throw Error('approval requires a decision identity and revision');
|
|
15
|
+
const task = readWorkTask(taskId, { ...options, root });
|
|
16
|
+
if (task.host !== 'codex') throw Error('native host approval has no remote capability; respond inside Claude');
|
|
17
|
+
const work = beginWork({ root, taskId, host: task.host, kind: 'approve', expectedRevision, operationId, data: { decisionId } }, options);
|
|
18
|
+
if (work.replay) return publicWorkTask(work.task);
|
|
19
|
+
try {
|
|
20
|
+
if (task.links.runs.length !== 1 || !(task.decisions || []).some(d => d.decisionId === decisionId)) throw Error('decision is missing, stale or ambiguous');
|
|
21
|
+
const controller = fileURLToPath(new URL('../codex-pipeline.mjs', import.meta.url));
|
|
22
|
+
const result = execute(process.execPath, [controller, 'approve-task', task.links.runs[0], '--decision', decisionId, '--operation', work.operation.operationId],
|
|
23
|
+
{ encoding: 'utf8', env: { ...process.env, ...(options.store ? { GREAT_CTO_TASKS_DIR: options.store } : {}), GREAT_CTO_WORK_LEASE: work.lease.token } });
|
|
24
|
+
const code = result.error ? 2 : result.status ?? 2;
|
|
25
|
+
finishWork(taskId, work.operation.operationId, code, { ...options, root });
|
|
26
|
+
if (code !== 0) throw Error('host refused approval: ' + String(result.stderr || result.error?.message || 'inspect host state').trim().slice(0, 500));
|
|
27
|
+
return publicWorkTask(readWorkTask(taskId, { ...options, root }));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
const latest = readWorkTask(taskId, { ...options, root });
|
|
30
|
+
if (latest.operations.find(o => o.operationId === work.operation.operationId)?.state === 'running') finishWork(taskId, work.operation.operationId, 2, { ...options, root });
|
|
31
|
+
throw e;
|
|
32
|
+
} finally { work.lease.release(); }
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Cohort measurements are evidence; they do not change authority or auto-approve. */
|
|
2
|
+
const median = values => { const s = values.filter(Number.isFinite).sort((a,b) => a-b); return !s.length ? null : s.length % 2 ? s[(s.length-1)/2] : (s[s.length/2-1]+s[s.length/2])/2; };
|
|
3
|
+
export function measureWork(tasks) {
|
|
4
|
+
const cohorts = [];
|
|
5
|
+
for (const host of ['claude-code', 'codex']) for (const intent of ['delivery', 'research']) {
|
|
6
|
+
const rows = tasks.filter(t => t.host === host && (t.intent || 'delivery') === intent);
|
|
7
|
+
const resumes = rows.flatMap(t => t.operations.filter(o => o.kind === 'resume' && o.state === 'host_returned'));
|
|
8
|
+
const known = resumes.filter(o => o.resume?.state && o.resume.state !== 'unobserved');
|
|
9
|
+
const reasons = {};
|
|
10
|
+
for (const t of rows) for (const [kind, count] of Object.entries(t.metrics?.interruptions || {})) reasons[kind] = (reasons[kind] || 0) + count;
|
|
11
|
+
const technical = Object.entries(reasons).filter(([kind]) => !['host_approval','native_pipeline_approval','native_permission_or_input'].includes(kind)).reduce((n, [,v]) => n+v, 0);
|
|
12
|
+
cohorts.push({ host, intent, tasks: rows.length, observedStarts: rows.filter(t => Number.isFinite(t.metrics?.timeToObservedStartMs)).length,
|
|
13
|
+
medianTimeToObservedStartMs: median(rows.map(t => t.metrics?.timeToObservedStartMs)), interruptions: reasons,
|
|
14
|
+
technicalInterruptionsPerTask: rows.length ? technical/rows.length : null, resumes: resumes.length, knownResumes: known.length,
|
|
15
|
+
progressedResumes: known.filter(o => o.resume.state === 'progressed').length,
|
|
16
|
+
resumeProgressRate: known.length ? known.filter(o => o.resume.state === 'progressed').length / known.length : null,
|
|
17
|
+
completed: rows.filter(t => t.phase === 'completed').length, blocked: rows.filter(t => t.phase === 'blocked').length,
|
|
18
|
+
verificationRework: rows.reduce((n,t) => n + (t.rework?.attempts || 0), 0) });
|
|
19
|
+
}
|
|
20
|
+
return { schemaVersion: 1, observation: 'task-stage-progress, not model latency or production defect rate', cohorts };
|
|
21
|
+
}
|
|
22
|
+
export function compareWork(current, baseline, { minTasks = 20, minKnownResumes = 5 } = {}) {
|
|
23
|
+
if (!Number.isInteger(minTasks) || minTasks < 1 || !Number.isInteger(minKnownResumes) || minKnownResumes < 1) throw Error('invalid comparison sample policy');
|
|
24
|
+
return { automaticAuthorityChanges: false, cohorts: current.cohorts.map(c => {
|
|
25
|
+
const b = baseline?.cohorts?.find(x => x.host === c.host && x.intent === c.intent);
|
|
26
|
+
const sufficient = !!b && c.tasks >= minTasks && b.tasks >= minTasks && c.knownResumes >= minKnownResumes && b.knownResumes >= minKnownResumes
|
|
27
|
+
&& Number.isFinite(c.technicalInterruptionsPerTask) && Number.isFinite(b.technicalInterruptionsPerTask) && Number.isFinite(c.resumeProgressRate) && Number.isFinite(b.resumeProgressRate);
|
|
28
|
+
return { host: c.host, intent: c.intent, state: !sufficient ? 'insufficient_evidence' : c.technicalInterruptionsPerTask < b.technicalInterruptionsPerTask && c.resumeProgressRate >= b.resumeProgressRate ? 'candidate_for_review' : 'no_measured_improvement',
|
|
29
|
+
reason: !sufficient ? 'Matched host/intent baseline and known resume sample are required' : 'Measurement can recommend review; safety, defect evidence and authority still require separate decisions' };
|
|
30
|
+
}) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Explicit operator attestations, bound to criteria, actual bytes and host state. */
|
|
2
|
+
import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, relative, isAbsolute, sep, join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { treeReceipt } from './receipt.mjs';
|
|
6
|
+
import { codexRunStore } from './codex-host-state.mjs';
|
|
7
|
+
import { assertExternal, readWorkTask, mutateWorkTask, beginWork, finishWork, publicWorkTask } from './work-tasks.mjs';
|
|
8
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
+
export function readOutcomeEvidence(root, file) {
|
|
11
|
+
const path = resolve(file); assertExternal(root, path);
|
|
12
|
+
const info = lstatSync(path);
|
|
13
|
+
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) || info.size > 65536) throw Error('evidence must be an external private regular file (0600, <=64 KiB)');
|
|
14
|
+
const raw = readFileSync(path); return { document: JSON.parse(raw), digest: hash(raw) };
|
|
15
|
+
}
|
|
16
|
+
function artifact(root, path) {
|
|
17
|
+
if (typeof path !== 'string' || !path || isAbsolute(path) || path.split(/[\\/]/).includes('..') || path.includes('\\')) throw Error('invalid outcome artifact path');
|
|
18
|
+
const resolved = resolve(root, path), actual = realpathSync(resolved), rel = relative(root, actual);
|
|
19
|
+
if (actual !== resolved || !rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel) || !lstatSync(actual).isFile()) throw Error('outcome artifact must be a project regular file without symlinks');
|
|
20
|
+
return { path, sha256: hash(readFileSync(actual)) };
|
|
21
|
+
}
|
|
22
|
+
export function assertHostSettled(task, { runsStore = codexRunStore() } = {}) {
|
|
23
|
+
if ((task.decisions || []).length || ['working', 'needs_decision', 'publishing', 'cancelled', 'blocked'].includes(task.phase)) throw Error('host state is not settled for acceptance');
|
|
24
|
+
if (task.host === 'claude-code') {
|
|
25
|
+
if (task.links.sessions.length !== 1 || (task.managed === false && !task.nativeSessionEnded)) throw Error('native session ownership is not settled');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (task.links.runs.length !== 1) throw Error('controlled run link is missing or ambiguous');
|
|
29
|
+
const id = task.links.runs[0], path = join(runsStore, id + '.json'), info = lstatSync(path);
|
|
30
|
+
if (info.isSymbolicLink() || (info.mode & 0o077)) throw Error('unsafe controlled run state');
|
|
31
|
+
const run = JSON.parse(readFileSync(path, 'utf8'));
|
|
32
|
+
if (run.id !== id || run.taskId !== task.taskId || run.root !== task.root || run.status !== 'done' || run.active || run.pending || run.wave?.status === 'running') throw Error('controlled run has not reached a settled terminal state');
|
|
33
|
+
if (run.release && (run.release.status !== 'verified' || run.release.smoke?.state !== 'passed')) throw Error('release has no verified publication/smoke evidence');
|
|
34
|
+
}
|
|
35
|
+
function verifyDocument(task, document) {
|
|
36
|
+
if (!document || document.taskId !== task.taskId || document.goal !== task.goal || document.revision !== task.revision
|
|
37
|
+
|| document.kind !== (task.intent || 'delivery') || document.goalSatisfied !== true || !task.acceptance.length) throw Error('evidence must bind task, goal, revision, intent and explicit criteria');
|
|
38
|
+
if (!Array.isArray(document.criteria) || document.criteria.length !== task.acceptance.length
|
|
39
|
+
|| document.criteria.some((c, i) => !c || c.index !== i || c.text !== task.acceptance[i] || c.state !== 'passed' || typeof c.evidence !== 'string' || !c.evidence.trim())) throw Error('each acceptance criterion needs ordered, explicit passing evidence');
|
|
40
|
+
if (!Array.isArray(document.artifacts) || !document.artifacts.length || new Set(document.artifacts.map(a => a?.path)).size !== document.artifacts.length) throw Error('unique outcome artifacts are required');
|
|
41
|
+
const artifacts = document.artifacts.map(a => {
|
|
42
|
+
const current = artifact(task.root, a.path);
|
|
43
|
+
if (a.sha256 !== current.sha256) throw Error('outcome artifact bytes do not match evidence');
|
|
44
|
+
if (task.intent === 'research' && !/^(docs|research|reports)\//.test(a.path)) throw Error('research outcomes contain reports, not implementation artifacts');
|
|
45
|
+
return current;
|
|
46
|
+
});
|
|
47
|
+
const receipt = treeReceipt(task.root);
|
|
48
|
+
if (!receipt) throw Error('outcome evidence requires a readable Git receipt');
|
|
49
|
+
return { kind: document.kind, state: 'verified', source: 'operator-attestation', evidenceDigest: hash(JSON.stringify(document)), receipt,
|
|
50
|
+
artifacts, criteria: document.criteria, verifiedAt: new Date().toISOString() };
|
|
51
|
+
}
|
|
52
|
+
export function applyOutcome({ root, taskId, kind, expectedRevision, operationId, evidenceFile }, options = {}) {
|
|
53
|
+
if (!['verify', 'complete'].includes(kind) || !Number.isInteger(expectedRevision)) throw Error('verify/complete require an explicit revision');
|
|
54
|
+
const task = readWorkTask(taskId, { ...options, root });
|
|
55
|
+
const evidence = kind === 'verify' ? readOutcomeEvidence(root, evidenceFile) : null;
|
|
56
|
+
const work = beginWork({ root, host: task.host, taskId, kind, expectedRevision, operationId,
|
|
57
|
+
data: evidence ? { evidenceDigest: evidence.digest } : null }, options);
|
|
58
|
+
if (work.replay) return publicWorkTask(work.task);
|
|
59
|
+
try {
|
|
60
|
+
assertHostSettled(task, options);
|
|
61
|
+
const current = readWorkTask(taskId, { ...options, root });
|
|
62
|
+
if (kind === 'verify') {
|
|
63
|
+
const outcome = verifyDocument(task, evidence.document);
|
|
64
|
+
mutateWorkTask(taskId, t => { t.outcome = outcome; t.phase = 'verified'; t.reason = 'Criteria and artifact identity recorded by explicit operator attestation'; }, { ...options, root });
|
|
65
|
+
} else {
|
|
66
|
+
const outcome = current.outcome;
|
|
67
|
+
if (current.phase !== 'verified' || outcome?.state !== 'verified' || !same(treeReceipt(root), outcome.receipt)) throw Error('verified outcome is missing or its tree receipt is stale');
|
|
68
|
+
for (const a of outcome.artifacts) if (artifact(task.root, a.path).sha256 !== a.sha256) throw Error('verified outcome artifact changed');
|
|
69
|
+
mutateWorkTask(taskId, t => { t.outcome.state = 'completed'; t.outcome.completedAt = new Date().toISOString(); t.phase = 'completed'; t.reason = 'Explicit completion of the verified requested outcome'; }, { ...options, root });
|
|
70
|
+
}
|
|
71
|
+
finishWork(taskId, work.operation.operationId, 0, { ...options, root });
|
|
72
|
+
return publicWorkTask(readWorkTask(taskId, { ...options, root }));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (readWorkTask(taskId, { ...options, root }).operations.find(o => o.operationId === work.operation.operationId)?.state === 'running') finishWork(taskId, work.operation.operationId, 2, { ...options, root });
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
finally { work.lease.release(); }
|
|
78
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/** Operator-owned intent and execution receipts, outside worker projects. */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, realpathSync, rmSync, existsSync, lstatSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, relative, isAbsolute, sep, basename, dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { treeReceipt } from './receipt.mjs';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const workTaskStore = () => process.env.GREAT_CTO_TASKS_DIR || join(homedir(), '.great_cto', 'work-tasks');
|
|
9
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
10
|
+
const hosts = new Set(['codex', 'claude-code']);
|
|
11
|
+
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
12
|
+
export const projectIdentity = root => 'project:' + createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
13
|
+
const canonical = root => realpathSync(root);
|
|
14
|
+
const fileFor = (store, id) => { if (!UUID.test(id || '')) throw Error('invalid task UUID'); return join(store, `${id}.json`); };
|
|
15
|
+
export function assertExternal(root, store) {
|
|
16
|
+
let actual = resolve(store), suffix = [];
|
|
17
|
+
while (!existsSync(actual)) { suffix.unshift(basename(actual)); actual = dirname(actual); }
|
|
18
|
+
actual = join(realpathSync(actual), ...suffix);
|
|
19
|
+
const rel = relative(canonical(root), actual);
|
|
20
|
+
if (!rel || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) throw Error('task store must be operator-owned outside the worker project');
|
|
21
|
+
if (existsSync(store) && lstatSync(store).isSymbolicLink()) throw Error('task store cannot be a symlink');
|
|
22
|
+
}
|
|
23
|
+
function ensureStore(root, store) { assertExternal(root, store); mkdirSync(store, { recursive: true, mode: 0o700 }); if (lstatSync(store).mode & 0o077) throw Error('task store permissions must be 0700'); }
|
|
24
|
+
function validate(t) {
|
|
25
|
+
if (!t || t.schemaVersion !== 1 || !UUID.test(t.taskId) || !hosts.has(t.host) || typeof t.root !== 'string'
|
|
26
|
+
|| typeof t.goal !== 'string' || !Array.isArray(t.acceptance) || t.acceptance.some(v => typeof v !== 'string')
|
|
27
|
+
|| !Number.isInteger(t.revision) || t.revision < 1 || !Array.isArray(t.operations)
|
|
28
|
+
|| t.operations.some(o => !o || !UUID.test(o.operationId) || typeof o.requestDigest !== 'string' || !['start', 'resume', 'approve', 'verify', 'complete'].includes(o.kind) || !['running', 'host_returned'].includes(o.state))
|
|
29
|
+
|| !Array.isArray(t.links?.runs) || t.links.runs.some(id => !UUID.test(id))
|
|
30
|
+
|| !Array.isArray(t.links?.sessions) || t.links.sessions.some(id => !UUID.test(id))
|
|
31
|
+
|| !Array.isArray(t.links?.issues) || t.links.issues.some(id => typeof id !== 'string')
|
|
32
|
+
|| !Array.isArray(t.observations) || !Array.isArray(t.evidence) || !t.metrics || typeof t.metrics.interruptions !== 'object'
|
|
33
|
+
|| !['accepted', 'working', 'needs_decision', 'blocked', 'waiting', 'unknown', 'cancelled', 'verified', 'publishing', 'completed'].includes(t.phase)) throw Error('invalid task state');
|
|
34
|
+
if (t.intent !== undefined && !['delivery', 'research'].includes(t.intent)) throw Error('invalid task intent');
|
|
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
|
+
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
|
+
return t;
|
|
38
|
+
}
|
|
39
|
+
export function readWorkTask(id, { store = workTaskStore(), root = null } = {}) {
|
|
40
|
+
const file = fileFor(store, id);
|
|
41
|
+
const info = lstatSync(file);
|
|
42
|
+
if (info.isSymbolicLink()) throw Error('task state cannot be a symlink');
|
|
43
|
+
if (info.mode & 0o077) throw Error('task state permissions must be 0600');
|
|
44
|
+
const t = validate(JSON.parse(readFileSync(file, 'utf8')));
|
|
45
|
+
if (t.taskId !== id || (root && canonical(t.root) !== canonical(root))) throw Error('task does not belong to this project');
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
export function listWorkTasks(root, { store = workTaskStore() } = {}) {
|
|
49
|
+
assertExternal(root, store);
|
|
50
|
+
if (existsSync(store) && (lstatSync(store).mode & 0o077)) throw Error('task store permissions must be 0700');
|
|
51
|
+
const projectRoot = canonical(root); let names;
|
|
52
|
+
try { names = readdirSync(store); } catch (e) { if (e.code === 'ENOENT') return { state: 'absent', tasks: [], unreadable: 0 }; throw e; }
|
|
53
|
+
const tasks = []; let unreadable = 0;
|
|
54
|
+
for (const name of names.filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json')).sort()) {
|
|
55
|
+
try {
|
|
56
|
+
const t = readWorkTask(name.slice(0, -5), { store });
|
|
57
|
+
if (t.root === projectRoot) tasks.push(t);
|
|
58
|
+
} catch { unreadable++; }
|
|
59
|
+
}
|
|
60
|
+
return { state: unreadable ? 'degraded' : 'ok', tasks, unreadable };
|
|
61
|
+
}
|
|
62
|
+
function save(t, store) {
|
|
63
|
+
const file = fileFor(store, t.taskId), tmp = `${file}.${randomUUID()}.tmp`;
|
|
64
|
+
writeFileSync(tmp, JSON.stringify(t, null, 2), { mode: 0o600 }); renameSync(tmp, file);
|
|
65
|
+
}
|
|
66
|
+
export function mutateWorkTask(id, fn, { store = workTaskStore(), root = null } = {}) {
|
|
67
|
+
const lock = `${fileFor(store, id)}.write-lock`; let acquired = false;
|
|
68
|
+
for (let i = 0; i < 10; i++) {
|
|
69
|
+
try { mkdirSync(lock, { mode: 0o700 }); acquired = true; break; }
|
|
70
|
+
catch (e) { if (e.code !== 'EEXIST') throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15); }
|
|
71
|
+
}
|
|
72
|
+
if (!acquired) throw Error('task metadata is owned by another writer; inspect the write lock');
|
|
73
|
+
try {
|
|
74
|
+
const t = readWorkTask(id, { store, root });
|
|
75
|
+
if (fn(t) === false) return t;
|
|
76
|
+
t.revision++; t.updatedAt = new Date().toISOString(); save(t, store); return t;
|
|
77
|
+
} finally { rmSync(lock, { recursive: true }); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function acquireProjectLease(root, { store = workTaskStore(), reuseToken = null } = {}) {
|
|
81
|
+
root = canonical(root); ensureStore(root, store);
|
|
82
|
+
const dir = join(store, 'leases'); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
const lock = join(dir, digest(root) + '.lock');
|
|
84
|
+
if (reuseToken) {
|
|
85
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
86
|
+
if (owner.token !== reuseToken || owner.pid !== process.ppid || owner.projectId !== projectIdentity(root)) throw Error('invalid project lease handoff');
|
|
87
|
+
return { token: owner.token, release() {} };
|
|
88
|
+
}
|
|
89
|
+
try { mkdirSync(lock, { mode: 0o700 }); }
|
|
90
|
+
catch (e) { if (e.code === 'EEXIST') throw Error('project execution is already owned; inspect its lease before recovery'); throw e; }
|
|
91
|
+
const token = randomUUID();
|
|
92
|
+
try { writeFileSync(join(lock, 'owner.json'), JSON.stringify({ token, pid: process.pid, projectId: projectIdentity(root), acquiredAt: new Date().toISOString() }), { mode: 0o600 }); }
|
|
93
|
+
catch (e) { rmSync(lock, { recursive: true }); throw e; }
|
|
94
|
+
return { token, release() {
|
|
95
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
96
|
+
if (owner.token !== token || owner.pid !== process.pid) throw Error('project lease ownership changed');
|
|
97
|
+
rmSync(lock, { recursive: true });
|
|
98
|
+
} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function newTaskRecord({ root, host, goal, acceptance = [], authority = null, intent = 'delivery', maxAttempts = 3 }) {
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
return { schemaVersion: 1, taskId: randomUUID(), root, managed: true, projectId: projectIdentity(root), host,
|
|
104
|
+
goal: goal.trim(), acceptance: [...acceptance], authority, intent,
|
|
105
|
+
budget: { maxStageAttempts: host === 'codex' ? maxAttempts : null, authority: 'host-owned' },
|
|
106
|
+
stage: null, decisions: [], outcome: null, activity: [],
|
|
107
|
+
revision: 1, createdAt: now, updatedAt: now, phase: 'accepted', reason: null,
|
|
108
|
+
links: { runs: [], sessions: [], issues: [] }, operations: [], observations: [], evidence: [],
|
|
109
|
+
metrics: { firstObservedStartAt: null, timeToObservedStartMs: null, interruptions: {} } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function beginWork({ root, host, kind = 'start', goal = null, acceptance = [], authority = null,
|
|
113
|
+
taskId = null, operationId = randomUUID(), expectedRevision = null, intent = 'delivery', maxAttempts = 3, data = null }, { store = workTaskStore() } = {}) {
|
|
114
|
+
root = canonical(root);
|
|
115
|
+
if (!hosts.has(host) || !['start', 'resume', 'approve', 'verify', 'complete'].includes(kind) || !UUID.test(operationId)) throw Error('invalid work operation');
|
|
116
|
+
if (kind === 'start' && (typeof goal !== 'string' || !goal.trim() || !Array.isArray(acceptance) || acceptance.some(v => typeof v !== 'string' || !v.trim()))) throw Error('goal and acceptance must be explicit strings');
|
|
117
|
+
if (!['delivery', 'research'].includes(intent) || !Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) throw Error('invalid intent or stage attempt budget');
|
|
118
|
+
const requestDigest = digest({ root, host, kind, goal, acceptance, authority, taskId, expectedRevision, intent, maxAttempts, data });
|
|
119
|
+
const legacyDigest = digest({ root, host, kind, goal, acceptance, authority, taskId, expectedRevision });
|
|
120
|
+
const matchesRequest = (t, op) => op.requestDigest === requestDigest || (t.intent == null && intent === 'delivery' && maxAttempts === 3 && data === null && op.requestDigest === legacyDigest);
|
|
121
|
+
const prior = listWorkTasks(root, { store });
|
|
122
|
+
if (prior.state === 'degraded') throw Error('task state is unreadable; selection is blocked');
|
|
123
|
+
for (const t of prior.tasks) {
|
|
124
|
+
const op = t.operations.find(o => o.operationId === operationId);
|
|
125
|
+
if (!op) continue;
|
|
126
|
+
if (!matchesRequest(t, op)) throw Error('idempotency key conflicts with another request');
|
|
127
|
+
return { task: t, operation: op, replay: true, lease: null };
|
|
128
|
+
}
|
|
129
|
+
const lease = acquireProjectLease(root, { store });
|
|
130
|
+
try {
|
|
131
|
+
const underLease = listWorkTasks(root, { store });
|
|
132
|
+
if (underLease.state === 'degraded') throw Error('task state is unreadable');
|
|
133
|
+
for (const t of underLease.tasks) {
|
|
134
|
+
const existing = t.operations.find(o => o.operationId === operationId);
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (!matchesRequest(t, existing)) throw Error('idempotency key conflicts with another request');
|
|
137
|
+
lease.release(); return { task: t, operation: existing, replay: true, lease: null };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const now = new Date().toISOString(); let task;
|
|
141
|
+
if (kind !== 'start') {
|
|
142
|
+
task = readWorkTask(taskId, { store, root });
|
|
143
|
+
if (kind === 'resume' && task.managed === false) throw Error('observed native session has no managed ownership; resume inside its native host');
|
|
144
|
+
if (['cancelled', 'completed'].includes(task.phase)) throw Error('terminal task cannot resume');
|
|
145
|
+
if (task.operations.some(o => o.state === 'running')) throw Error('previous host operation is not reconciled; inspect it before resume');
|
|
146
|
+
if (task.host !== host) throw Error('task host cannot change during resume');
|
|
147
|
+
if (expectedRevision !== null && task.revision !== expectedRevision) throw Error('stale task revision; refresh before resume');
|
|
148
|
+
} else {
|
|
149
|
+
task = newTaskRecord({ root, host, goal, acceptance, authority, intent, maxAttempts });
|
|
150
|
+
save(task, store);
|
|
151
|
+
}
|
|
152
|
+
const operation = { operationId, requestDigest, kind, data, state: 'running', startedAt: now, finishedAt: null, exitCode: null,
|
|
153
|
+
baseline: kind === 'resume' ? (task.activity || []).map(a => a.id) : null };
|
|
154
|
+
task = mutateWorkTask(task.taskId, t => { t.operations.push(operation); }, { store, root });
|
|
155
|
+
return { task, operation, replay: false, lease };
|
|
156
|
+
} catch (e) { lease.release(); throw e; }
|
|
157
|
+
}
|
|
158
|
+
export function finishWork(taskId, operationId, exitCode, { store = workTaskStore(), root = null } = {}) {
|
|
159
|
+
return mutateWorkTask(taskId, t => {
|
|
160
|
+
const op = t.operations.find(o => o.operationId === operationId);
|
|
161
|
+
if (!op || op.state !== 'running') throw Error('operation is not running');
|
|
162
|
+
op.state = 'host_returned'; op.exitCode = exitCode; op.finishedAt = new Date().toISOString();
|
|
163
|
+
if (op.kind === 'resume') {
|
|
164
|
+
const advanced = (t.activity || []).filter(a => !(op.baseline || []).includes(a.id) && ['verified_stage', 'native_stage_result'].includes(a.kind));
|
|
165
|
+
op.resume = { state: advanced.length ? 'progressed' : t.phase === 'needs_decision' ? 'waiting_decision' : t.phase === 'blocked' ? 'blocked' : 'unobserved', evidence: advanced.map(a => a.id) };
|
|
166
|
+
}
|
|
167
|
+
// Host exit does not prove acceptance, and absence of hooks does not prove progress.
|
|
168
|
+
if (['start', 'resume'].includes(op.kind) && (!t.observations.length || (t.host === 'claude-code' && t.phase === 'working'))) { t.phase = exitCode === 0 ? 'unknown' : 'blocked'; t.reason = 'Host returned; no execution observation recorded'; }
|
|
169
|
+
}, { store, root });
|
|
170
|
+
}
|
|
171
|
+
export function linkWork(taskId, type, id, { store = workTaskStore(), root, host } = {}) {
|
|
172
|
+
if (!['runs', 'sessions', 'issues'].includes(type) || typeof id !== 'string' || !id) throw Error('invalid host link');
|
|
173
|
+
if (type !== 'issues' && !UUID.test(id)) throw Error('invalid execution UUID');
|
|
174
|
+
return mutateWorkTask(taskId, t => {
|
|
175
|
+
if (host && t.host !== host) throw Error('host link mismatch');
|
|
176
|
+
if (t.links[type].includes(id)) return false;
|
|
177
|
+
t.links[type].push(id);
|
|
178
|
+
}, { store, root });
|
|
179
|
+
}
|
|
180
|
+
function observe(t, { phase, reason = null, kind, at = new Date().toISOString() }) {
|
|
181
|
+
if (t.phase === phase && t.reason === reason && t.observations.at(-1)?.kind === kind) return false;
|
|
182
|
+
if (['verified', 'completed'].includes(t.phase)) { t.outcome = null; }
|
|
183
|
+
t.phase = phase; t.reason = reason; t.observations.push({ kind, phase, reason, at });
|
|
184
|
+
if (phase === 'working' && !t.metrics.firstObservedStartAt) {
|
|
185
|
+
t.metrics.firstObservedStartAt = at; t.metrics.timeToObservedStartMs = Math.max(0, Date.parse(at) - Date.parse(t.createdAt));
|
|
186
|
+
}
|
|
187
|
+
if (['needs_decision', 'blocked'].includes(phase) && kind !== 'native_session') t.metrics.interruptions[kind] = (t.metrics.interruptions[kind] || 0) + 1;
|
|
188
|
+
}
|
|
189
|
+
export function observeWorkRun(state, { store = workTaskStore() } = {}) {
|
|
190
|
+
if (!state.taskId) return null;
|
|
191
|
+
return mutateWorkTask(state.taskId, t => {
|
|
192
|
+
if (t.phase === 'completed') return false;
|
|
193
|
+
if (t.host !== 'codex' || !t.links.runs.includes(state.id)) throw Error('run is not linked to task');
|
|
194
|
+
let evidenceChanged = false;
|
|
195
|
+
const started = (state.attempts || []).find(a => a.startedAt)?.startedAt;
|
|
196
|
+
if (started && !t.metrics.firstObservedStartAt) {
|
|
197
|
+
evidenceChanged = true;
|
|
198
|
+
t.metrics.firstObservedStartAt = started; t.metrics.timeToObservedStartMs = Math.max(0, Date.parse(started) - Date.parse(t.createdAt));
|
|
199
|
+
}
|
|
200
|
+
const status = state.status;
|
|
201
|
+
const phase = ['blocked', 'manual-action', 'join-wait'].includes(status) ? 'blocked' : state.active || state.wave?.status === 'running' ? 'working'
|
|
202
|
+
: ['awaiting-gate', 'awaiting-release'].includes(status) ? 'needs_decision'
|
|
203
|
+
: status === 'blocked' || status === 'manual-action' || status === 'join-wait' ? 'blocked'
|
|
204
|
+
: status === 'done' ? 'waiting' : status === 'cancelled' ? 'cancelled' : 'accepted';
|
|
205
|
+
const reason = status === 'done' ? 'Run finished; acceptance evidence must still be recorded'
|
|
206
|
+
: state.reason || (phase === 'needs_decision' ? 'Host approval required' : null);
|
|
207
|
+
t.stage = state.active || state.pending?.role || state.queue?.[0] || null;
|
|
208
|
+
const nextDecisions = controlledDecisions(state);
|
|
209
|
+
evidenceChanged ||= JSON.stringify(nextDecisions) !== JSON.stringify(t.decisions || []);
|
|
210
|
+
t.decisions = nextDecisions;
|
|
211
|
+
t.activity ||= [];
|
|
212
|
+
for (const [role, r] of Object.entries(state.results || {})) {
|
|
213
|
+
if (r.verification?.state !== 'verified' || !r.digest || !r.receipt) continue;
|
|
214
|
+
const id = digest({ run: state.id, role, result: r.digest });
|
|
215
|
+
if (!t.activity.some(a => a.id === id)) { t.activity.push({ id, kind: 'verified_stage', role, at: r.at || new Date().toISOString() }); evidenceChanged = true; }
|
|
216
|
+
}
|
|
217
|
+
t.rework = { attempts: (state.attempts || []).filter(a => a.status === 'rework').length, maxStageAttempts: state.maxAttempts ?? null };
|
|
218
|
+
const nextEvidence = Object.entries(state.results || {}).map(([role, r]) => ({ role, verdict: r.verdict || null, verified: r.verification?.state === 'verified' }));
|
|
219
|
+
evidenceChanged ||= JSON.stringify(nextEvidence) !== JSON.stringify(t.evidence);
|
|
220
|
+
t.evidence = nextEvidence;
|
|
221
|
+
const observed = observe(t, { phase, reason, kind: phase === 'needs_decision' ? 'host_approval' : status === 'manual-action' ? 'manual_action' : 'controlled_run' });
|
|
222
|
+
return observed === false && !evidenceChanged ? false : undefined;
|
|
223
|
+
}, { store, root: state.root });
|
|
224
|
+
}
|
|
225
|
+
export function registerNativeStart(payload, { store = workTaskStore() } = {}) {
|
|
226
|
+
if (payload?.hook_event_name !== 'UserPromptSubmit' || !UUID.test(payload.session_id || '')) return null;
|
|
227
|
+
const match = String(payload.prompt || '').match(/^\/(?:great[-_]cto:)?(start|audit)\s+([\s\S]+)$/);
|
|
228
|
+
if (!match || !match[2].trim()) return null;
|
|
229
|
+
const root = canonical(payload.cwd), lease = acquireProjectLease(root, { store });
|
|
230
|
+
try {
|
|
231
|
+
const listing = listWorkTasks(root, { store });
|
|
232
|
+
if (listing.state === 'degraded') throw Error('task state is unreadable');
|
|
233
|
+
if (listing.tasks.some(t => !['completed', 'cancelled'].includes(t.phase) && t.links.sessions.includes(payload.session_id))) return null;
|
|
234
|
+
const task = newTaskRecord({ root, host: 'claude-code', goal: match[2], intent: match[1] === 'audit' ? 'research' : 'delivery',
|
|
235
|
+
authority: { mode: 'native-interactive', writeScope: null } });
|
|
236
|
+
task.managed = false; task.links.sessions.push(payload.session_id);
|
|
237
|
+
observe(task, { phase: 'working', kind: 'native_session' }); save(task, store); return task;
|
|
238
|
+
} finally { lease.release(); }
|
|
239
|
+
}
|
|
240
|
+
export function observeWorkSession(payload, { store = workTaskStore() } = {}) {
|
|
241
|
+
if (!UUID.test(payload?.session_id || '') || !payload.cwd) return null;
|
|
242
|
+
const listing = listWorkTasks(payload.cwd, { store });
|
|
243
|
+
if (listing.state === 'degraded') throw Error('task metadata is unreadable');
|
|
244
|
+
let tasks = listing.tasks.filter(t => t.host === 'claude-code' && !['completed', 'cancelled'].includes(t.phase) && t.links.sessions.includes(payload.session_id));
|
|
245
|
+
if (!tasks.length) { const registered = registerNativeStart(payload, { store }); if (registered) return registered; }
|
|
246
|
+
if (tasks.length !== 1) return null;
|
|
247
|
+
const event = payload.hook_event_name;
|
|
248
|
+
const phase = event === 'UserPromptSubmit' ? 'working' : event === 'Notification' ? 'needs_decision'
|
|
249
|
+
: ['Stop', 'SessionEnd'].includes(event) ? 'waiting' : null;
|
|
250
|
+
if (!phase) return null;
|
|
251
|
+
return mutateWorkTask(tasks[0].taskId, t => {
|
|
252
|
+
if (event === 'Notification') t.decisions = [{ decisionId: digest({ session: payload.session_id, notification: String(payload.message || ''), type: payload.notification_type || null }), engine: 'claude-code', kind: 'native_input', sessionId: payload.session_id, state: 'pending', action: 'native_host', label: 'Respond inside the linked Claude session' }];
|
|
253
|
+
else if (event === 'UserPromptSubmit') t.decisions = (t.decisions || []).filter(d => d.kind === 'pipeline_gate');
|
|
254
|
+
if (event === 'SessionEnd') t.nativeSessionEnded = true;
|
|
255
|
+
return observe(t, { phase: (t.decisions || []).length ? 'needs_decision' : phase,
|
|
256
|
+
reason: event === 'Notification' ? String(payload.message || 'Native host input required').slice(0, 300) : null,
|
|
257
|
+
kind: event === 'Notification' ? 'native_permission_or_input' : 'native_session' }); }, { store, root: payload.cwd });
|
|
258
|
+
}
|
|
259
|
+
export function publicWorkTask(t) {
|
|
260
|
+
return { schemaVersion: 1, taskId: t.taskId, projectId: t.projectId, managed: t.managed, host: t.host, goal: t.goal,
|
|
261
|
+
acceptance: t.acceptance, authority: t.authority, intent: t.intent || 'delivery', budget: t.budget || null, stage: t.stage || null,
|
|
262
|
+
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
|
+
createdAt: t.createdAt, updatedAt: t.updatedAt, links: t.links, evidence: t.evidence, metrics: t.metrics,
|
|
264
|
+
operations: t.operations.map(({ operationId, kind, state, startedAt, finishedAt, exitCode, resume }) => ({ operationId, kind, state, startedAt, finishedAt, exitCode, ...(resume ? { resume } : {}) })) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function controlledDecisions(state) {
|
|
268
|
+
const pending = state.status === 'awaiting-release' ? state.release : state.pending;
|
|
269
|
+
if (!pending || !['awaiting-gate', 'awaiting-release'].includes(state.status)) return [];
|
|
270
|
+
const kind = state.status === 'awaiting-release' ? 'release' : 'gate';
|
|
271
|
+
const binding = digest({ run: state.id, kind, pending });
|
|
272
|
+
return [{ decisionId: binding, engine: 'codex', kind, runId: state.id, state: 'pending', action: 'host_approve',
|
|
273
|
+
receipt: pending.receipt || null,
|
|
274
|
+
label: kind === 'release' ? 'Approve the host release candidate' : 'Approve the host stage proposal',
|
|
275
|
+
gates: pending.gates || [], role: pending.role || 'devops', binding }];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Only the dispatcher's explicit session event may attach native stage evidence. */
|
|
279
|
+
export function observeNativePipeline({ root, sessionId, agent, verdict, decisionKind, gates = [], receipt = null, recordDigest = null }, { store = workTaskStore() } = {}) {
|
|
280
|
+
if (!UUID.test(sessionId || '')) return null;
|
|
281
|
+
const listing = listWorkTasks(root, { store });
|
|
282
|
+
if (listing.state === 'degraded') throw Error('task metadata is unreadable');
|
|
283
|
+
const matches = listing.tasks.filter(t => t.host === 'claude-code' && !['completed', 'cancelled'].includes(t.phase) && t.links.sessions.includes(sessionId));
|
|
284
|
+
if (matches.length !== 1) return null;
|
|
285
|
+
return mutateWorkTask(matches[0].taskId, t => {
|
|
286
|
+
t.stage = agent; t.activity ||= [];
|
|
287
|
+
const eventId = digest({ sessionId, agent, recordDigest, receipt, decisionKind, gates });
|
|
288
|
+
if (t.activity.some(a => a.id === eventId)) return false;
|
|
289
|
+
t.activity.push({ id: eventId, kind: 'native_pipeline_event', role: agent, at: new Date().toISOString() });
|
|
290
|
+
if (decisionKind === 'rework') t.metrics.interruptions.native_rework = (t.metrics.interruptions.native_rework || 0) + 1;
|
|
291
|
+
if (receipt && JSON.stringify(treeReceipt(root)) === JSON.stringify(receipt) && recordDigest && ['next', 'done'].includes(decisionKind)) {
|
|
292
|
+
const id = digest({ sessionId, agent, recordDigest, receipt });
|
|
293
|
+
if (!t.activity.some(a => a.id === id)) t.activity.push({ id, kind: 'native_stage_result', role: agent, at: new Date().toISOString() });
|
|
294
|
+
}
|
|
295
|
+
t.evidence = [...t.evidence.filter(e => e.role !== agent), { role: agent, verdict, verified: false }];
|
|
296
|
+
t.decisions = decisionKind === 'gate' ? [{ decisionId: digest({ sessionId, agent, recordDigest, receipt, gates }),
|
|
297
|
+
engine: 'claude-code', kind: 'pipeline_gate', state: 'pending', receipt, sessionId, gates, role: agent, action: 'native_host',
|
|
298
|
+
label: 'Resolve the bound pipeline gate inside Claude; shared state does not approve it' }] : [];
|
|
299
|
+
const phase = decisionKind === 'gate' ? 'needs_decision' : ['blocked', 'route-pending'].includes(decisionKind) ? 'blocked' : decisionKind === 'done' ? 'waiting' : 'working';
|
|
300
|
+
observe(t, { phase, kind: decisionKind === 'gate' ? 'native_pipeline_approval' : decisionKind === 'rework' ? 'native_rework' : 'native_pipeline' });
|
|
301
|
+
}, { store, root });
|
|
302
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Advanced operator contract behind `great-cto task work`; no new daily flow. */
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { listWorkTasks, readWorkTask } from './lib/work-tasks.mjs';
|
|
5
|
+
import { approveWorkDecision, decisionCapabilities } from './lib/work-decisions.mjs';
|
|
6
|
+
import { applyOutcome, readOutcomeEvidence } from './lib/work-outcomes.mjs';
|
|
7
|
+
import { measureWork, compareWork } from './lib/work-metrics.mjs';
|
|
8
|
+
const args = process.argv.slice(2), action = args.shift();
|
|
9
|
+
try {
|
|
10
|
+
const supported = new Set(['--dir','--task','--decision','--revision','--operation','--evidence','--baseline']);
|
|
11
|
+
const opts = {};
|
|
12
|
+
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
|
+
if (perAction[action] && Object.keys(opts).some(k => !perAction[action].includes(k))) throw Error('option is not supported for this operation');
|
|
15
|
+
const root = resolve(opts['--dir'] || '.'), taskId = opts['--task'];
|
|
16
|
+
let result;
|
|
17
|
+
if (action === 'metrics') {
|
|
18
|
+
const listing = listWorkTasks(root); if (listing.state === 'degraded') throw Error('task source is degraded; measurements refused');
|
|
19
|
+
const current = measureWork(listing.tasks), baseline = opts['--baseline'] ? readOutcomeEvidence(root, opts['--baseline']).document : null;
|
|
20
|
+
result = { ...current, comparison: compareWork(current, baseline) };
|
|
21
|
+
} else if (action === 'decisions') result = decisionCapabilities(readWorkTask(taskId, { root }));
|
|
22
|
+
else if (action === 'approve') result = approveWorkDecision({ root, taskId, decisionId: opts['--decision'], expectedRevision: Number(opts['--revision']), operationId: opts['--operation'] });
|
|
23
|
+
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]');
|
|
25
|
+
console.log(JSON.stringify(result, null, 2));
|
|
26
|
+
} catch (e) { console.error('work-task: ' + e.message); process.exitCode = 2; }
|
package/dist/codex-host.js
CHANGED
|
@@ -6,8 +6,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
6
6
|
export function codexControllerCandidates(moduleUrl = import.meta.url) {
|
|
7
7
|
const cliRoot = join(dirname(fileURLToPath(moduleUrl)), '..');
|
|
8
8
|
return [
|
|
9
|
-
join(cliRoot, 'board', 'scripts', 'codex-pipeline.mjs'),
|
|
10
9
|
join(cliRoot, '..', '..', 'scripts', 'codex-pipeline.mjs'),
|
|
10
|
+
join(cliRoot, 'board', 'scripts', 'codex-pipeline.mjs'),
|
|
11
11
|
];
|
|
12
12
|
}
|
|
13
13
|
export function runCodexHost(args, { exists = existsSync, spawn = spawnSync } = {}) {
|
package/dist/main.js
CHANGED
|
@@ -101,10 +101,12 @@ function parseArgs(argv) {
|
|
|
101
101
|
args.noLlm = true;
|
|
102
102
|
else if (a === "--host") {
|
|
103
103
|
const v = argv[++i] ?? "";
|
|
104
|
+
args.hostInput = v;
|
|
104
105
|
args.host = (v === "codex" || v === "claude-code") ? v : null;
|
|
105
106
|
}
|
|
106
107
|
else if (a.startsWith("--host=")) {
|
|
107
108
|
const v = a.slice("--host=".length);
|
|
109
|
+
args.hostInput = v;
|
|
108
110
|
args.host = (v === "codex" || v === "claude-code") ? v : null;
|
|
109
111
|
}
|
|
110
112
|
else if (a === "--bind")
|
|
@@ -158,11 +160,16 @@ function parseArgs(argv) {
|
|
|
158
160
|
args.taskArgs = argv.slice(i + 1);
|
|
159
161
|
break;
|
|
160
162
|
}
|
|
163
|
+
else if (a === "run" || a === "status" || a === "resume") {
|
|
164
|
+
args.command = a;
|
|
165
|
+
args.taskArgs = argv.slice(i + 1);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
161
168
|
// Slash-commands surfaced as CLI subcommands so users get a clear hint
|
|
162
169
|
// instead of a confusing usage error. These work only in the chat plugin.
|
|
163
170
|
else if (a === "start" || a === "audit" || a === "inbox" || a === "digest" ||
|
|
164
171
|
a === "review" || a === "doctor" || a === "burn" || a === "save" ||
|
|
165
|
-
a === "
|
|
172
|
+
a === "learn" || a === "agent" ||
|
|
166
173
|
a === "rfc" || a === "release" || a === "ownership" ||
|
|
167
174
|
a === "sec" || a === "poc" || a === "crystallize" ||
|
|
168
175
|
a === "migrate") {
|
|
@@ -719,10 +726,34 @@ async function runUninstall(args) {
|
|
|
719
726
|
warn(`${plan.keptVersions.length} plugin version(s) kept — a running session uses them; run uninstall again after closing it`);
|
|
720
727
|
return r.failed.length ? 1 : 0;
|
|
721
728
|
}
|
|
722
|
-
function printHelp() {
|
|
729
|
+
function printHelp(advanced = false) {
|
|
730
|
+
if (!advanced) {
|
|
731
|
+
log(`great-cto — describe the task, get a checked result
|
|
732
|
+
|
|
733
|
+
Get started:
|
|
734
|
+
great-cto init
|
|
735
|
+
|
|
736
|
+
Everyday use:
|
|
737
|
+
great-cto run "describe the task"
|
|
738
|
+
great-cto status
|
|
739
|
+
great-cto resume
|
|
740
|
+
|
|
741
|
+
Default host: interactive Claude Code with the plugin loaded.
|
|
742
|
+
For controlled Codex add --host codex; run also needs --allow src,tests,docs.
|
|
743
|
+
Use --dir PATH to choose a project, --dry-run to preview, or run --help for options.
|
|
744
|
+
Resume preserves pending decisions; it never approves them.
|
|
745
|
+
|
|
746
|
+
Details:
|
|
747
|
+
great-cto board Open the local dashboard
|
|
748
|
+
great-cto help --advanced All installation, runtime and operator commands`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
723
751
|
log(`${bold("great-cto")} — one-command install for the great_cto Claude Code plugin
|
|
724
752
|
|
|
725
753
|
${bold("Usage:")}
|
|
754
|
+
npx great-cto run "describe the task" [--host claude-code|codex] [--dir PATH]
|
|
755
|
+
npx great-cto status [RUN_UUID] [--host codex] [--json]
|
|
756
|
+
npx great-cto resume [RUN_UUID] [--host codex]
|
|
726
757
|
npx great-cto install [options] Same as init
|
|
727
758
|
npx great-cto [init] [options] Detect + bootstrap
|
|
728
759
|
npx great-cto board [--port 3141] [--no-open]
|
|
@@ -731,6 +762,7 @@ ${bold("Usage:")}
|
|
|
731
762
|
npx great-cto ci [path] [--no-archetype] [--no-budget]
|
|
732
763
|
npx great-cto mcp [--sse --port N]
|
|
733
764
|
npx great-cto adapt [--dry-run]
|
|
765
|
+
npx great-cto task work decisions|approve|verify|complete|metrics ...
|
|
734
766
|
npx great-cto codex-host doctor|list|start|resume|status ...
|
|
735
767
|
npx great-cto serve [--port 3142]
|
|
736
768
|
npx great-cto upgrade [superpowers|beads] Re-clone companions to latest tag + re-apply overlays
|
|
@@ -1528,7 +1560,7 @@ async function main() {
|
|
|
1528
1560
|
await finish(exitCode);
|
|
1529
1561
|
}
|
|
1530
1562
|
if (args.command === "help") {
|
|
1531
|
-
printHelp();
|
|
1563
|
+
printHelp(rawArgv.includes('--advanced'));
|
|
1532
1564
|
await finish(0);
|
|
1533
1565
|
}
|
|
1534
1566
|
if (args.command === "unknown") {
|
|
@@ -1622,6 +1654,10 @@ async function main() {
|
|
|
1622
1654
|
await finish(2);
|
|
1623
1655
|
}
|
|
1624
1656
|
}
|
|
1657
|
+
if (args.command === "task" && args.taskArgs?.[0] === "work") {
|
|
1658
|
+
const { runWorkTask } = await import("./work-task.js");
|
|
1659
|
+
await finish(runWorkTask(args.taskArgs.slice(1)));
|
|
1660
|
+
}
|
|
1625
1661
|
if (args.command === "task") {
|
|
1626
1662
|
const { runTask } = await import("./worker.js");
|
|
1627
1663
|
await finish(await runTask(args.taskArgs ?? []));
|
|
@@ -1634,6 +1670,11 @@ async function main() {
|
|
|
1634
1670
|
const { runCodexHost } = await import('./codex-host.js');
|
|
1635
1671
|
await finish(runCodexHost(args.taskArgs ?? []));
|
|
1636
1672
|
}
|
|
1673
|
+
if (args.command === "run" || args.command === "status" || args.command === "resume") {
|
|
1674
|
+
const { runDaily } = await import('./run.js');
|
|
1675
|
+
const dailyArgs = [...(args.hostInput !== undefined ? ['--host', args.hostInput] : []), ...(args.taskArgs ?? [])];
|
|
1676
|
+
await finish(runDaily(args.command, dailyArgs, { cwd: args.dir }));
|
|
1677
|
+
}
|
|
1637
1678
|
if (args.command === "serve") {
|
|
1638
1679
|
try {
|
|
1639
1680
|
const { runServe } = await import("./serve.js");
|