great-cto 3.47.0 → 3.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +44 -6
- package/board/scripts/lib/codex-pipeline.mjs +6 -4
- 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
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { newRun, runStage, runParallelWave, parallelPair, approve, recover, cancel } from './lib/codex-pipeline.mjs';
|
|
6
6
|
import { approveRelease } from './lib/codex-release.mjs';
|
|
7
7
|
import { codexRunStore, listCodexRuns, codexHostDoctor } from './lib/codex-host-state.mjs';
|
|
8
|
+
import { beginWork, finishWork, acquireProjectLease, readWorkTask, linkWork, observeWorkRun, controlledDecisions } from './lib/work-tasks.mjs';
|
|
8
9
|
import { detectClaude } from './lib/claude-exec.mjs';
|
|
9
10
|
|
|
10
11
|
// State is outside the worker workspace. A per-run exclusive lock covers the entire subprocess lifetime.
|
|
@@ -22,14 +23,17 @@ const routes = raw => {
|
|
|
22
23
|
}
|
|
23
24
|
return out;
|
|
24
25
|
};
|
|
26
|
+
const inheritedLease = process.env.GREAT_CTO_WORK_LEASE;
|
|
27
|
+
delete process.env.GREAT_CTO_WORK_LEASE;
|
|
25
28
|
const store = codexRunStore();
|
|
26
29
|
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
27
30
|
const save = state => {
|
|
28
31
|
const file = join(store, `${state.id}.json`);
|
|
29
32
|
writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
30
33
|
renameSync(`${file}.tmp`, file);
|
|
34
|
+
observeWorkRun(state);
|
|
31
35
|
};
|
|
32
|
-
let locked = null;
|
|
36
|
+
let locked = null, projectLease = null, work = null, taskForFinish = null;
|
|
33
37
|
try {
|
|
34
38
|
if (command === 'doctor') {
|
|
35
39
|
const result = codexHostDoctor({ pluginRoot: resolve(value('--plugin-root') || join(dirname(fileURLToPath(import.meta.url)), '..')), store });
|
|
@@ -42,6 +46,21 @@ try {
|
|
|
42
46
|
let state;
|
|
43
47
|
if (command === 'start') {
|
|
44
48
|
const root = realpathSync(resolve(value('--dir') || '.'));
|
|
49
|
+
const taskId = value('--task-id');
|
|
50
|
+
const allowed = (value('--allow') || '').split(',').map(p => p.trim()).filter(Boolean);
|
|
51
|
+
let acceptance = [], intent = value('--intent') || 'delivery', maxAttempts = value('--max-attempts') === null ? 3 : Number(value('--max-attempts'));
|
|
52
|
+
if (taskId) {
|
|
53
|
+
projectLease = acquireProjectLease(root, { reuseToken: inheritedLease });
|
|
54
|
+
const task = readWorkTask(taskId, { root });
|
|
55
|
+
acceptance = task.acceptance; intent = task.intent || 'delivery'; maxAttempts = task.budget?.maxStageAttempts || 3;
|
|
56
|
+
if (task.host !== 'codex' || task.goal !== value('--prompt')?.trim()
|
|
57
|
+
|| JSON.stringify(task.authority?.writeScope) !== JSON.stringify(allowed)) throw Error('task authority does not match run request');
|
|
58
|
+
} else {
|
|
59
|
+
work = beginWork({ root, host: 'codex', goal: value('--prompt'), intent, maxAttempts, authority: { mode: 'explicit-paths', writeScope: allowed } });
|
|
60
|
+
projectLease = work.lease; taskForFinish = work.task.taskId;
|
|
61
|
+
}
|
|
62
|
+
const listing = listCodexRuns({ root, store });
|
|
63
|
+
if (listing.state === 'degraded' || listing.runs.some(r => !['done', 'cancelled'].includes(r.status))) throw Error('unfinished or unreadable project run exists; resume or inspect it first');
|
|
45
64
|
if (args.includes('--routes') && value('--routes') == null) throw Error('--routes requires a value');
|
|
46
65
|
const hostRoutes = routes(value('--routes'));
|
|
47
66
|
if (Object.values(hostRoutes).includes('claude-code')) {
|
|
@@ -63,22 +82,39 @@ try {
|
|
|
63
82
|
releasePolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
64
83
|
}
|
|
65
84
|
state = newRun({ root, prompt: value('--prompt'), checkPolicy, releasePolicy,
|
|
66
|
-
allowed:
|
|
67
|
-
maxAttempts
|
|
85
|
+
allowed, intent, entry: intent === 'research' ? 'project-auditor' : value('--entry') || 'product-owner',
|
|
86
|
+
maxAttempts, hostRoutes });
|
|
87
|
+
state.taskId = taskId || work.task.taskId;
|
|
88
|
+
state.acceptance = acceptance;
|
|
89
|
+
linkWork(state.taskId, 'runs', state.id, { root, host: 'codex' });
|
|
68
90
|
save(state);
|
|
69
91
|
} else {
|
|
70
92
|
const id = args[0];
|
|
71
93
|
if (!/^[0-9a-f-]{36}$/.test(id || '')) throw Error('a run UUID is required');
|
|
72
94
|
state = JSON.parse(readFileSync(join(store, `${id}.json`), 'utf8'));
|
|
73
95
|
if (state.id !== id || state.version !== 1) throw Error('invalid run state');
|
|
96
|
+
if (command !== 'status') {
|
|
97
|
+
if (command === 'resume' && state.taskId && !inheritedLease) {
|
|
98
|
+
work = beginWork({ root: state.root, host: 'codex', kind: 'resume', taskId: state.taskId });
|
|
99
|
+
projectLease = work.lease; taskForFinish = work.task.taskId;
|
|
100
|
+
} else projectLease = acquireProjectLease(state.root, { reuseToken: inheritedLease });
|
|
101
|
+
}
|
|
74
102
|
}
|
|
75
|
-
if (!['start', 'resume', 'status', 'approve', 'approve-release', 'recover', 'cancel'].includes(command)) throw Error('expected start, resume, status, approve, approve-release, recover, cancel, list or doctor');
|
|
103
|
+
if (!['start', 'resume', 'status', 'approve', 'approve-release', 'recover', 'cancel', 'approve-task'].includes(command)) throw Error('expected start, resume, status, approve, approve-release, recover, cancel, list or doctor');
|
|
76
104
|
if (command !== 'status') {
|
|
77
105
|
const lock = join(store, `${state.id}.lock`);
|
|
78
106
|
mkdirSync(lock); locked = lock;
|
|
79
107
|
// Reload under lock so simultaneous approvals cannot overwrite one another.
|
|
80
108
|
state = JSON.parse(readFileSync(join(store, `${state.id}.json`), 'utf8'));
|
|
81
|
-
if (command === 'approve') {
|
|
109
|
+
if (command === 'approve-task') {
|
|
110
|
+
if (!inheritedLease || !state.taskId) throw Error('task approval requires operator operation ownership');
|
|
111
|
+
const task = readWorkTask(state.taskId, { root: state.root });
|
|
112
|
+
const operation = task.operations.find(o => o.operationId === value('--operation'));
|
|
113
|
+
const decision = controlledDecisions(state)[0];
|
|
114
|
+
if (!operation || operation.state !== 'running' || operation.kind !== 'approve' || operation.data?.decisionId !== value('--decision') || !decision || decision.decisionId !== value('--decision')) throw Error('task approval is stale or unbound');
|
|
115
|
+
if (decision.kind === 'release') approveRelease(state, state.release.token); else approve(state, state.pending.token);
|
|
116
|
+
save(state);
|
|
117
|
+
} else if (command === 'approve') { approve(state, value('--token')); save(state); }
|
|
82
118
|
else if (command === 'approve-release') { approveRelease(state, value('--token')); save(state); }
|
|
83
119
|
else if (command === 'recover') { recover(state); save(state); }
|
|
84
120
|
else if (command === 'cancel') { cancel(state); save(state); }
|
|
@@ -101,5 +137,7 @@ try {
|
|
|
101
137
|
console.error(`codex-pipeline: ${error.message}`);
|
|
102
138
|
process.exitCode = 2;
|
|
103
139
|
} finally {
|
|
104
|
-
if (locked) rmdirSync(locked);
|
|
140
|
+
try { if (locked) rmdirSync(locked);
|
|
141
|
+
if (taskForFinish) finishWork(taskForFinish, work.operation.operationId, process.exitCode || 0); }
|
|
142
|
+
finally { projectLease?.release(); }
|
|
105
143
|
}
|
|
@@ -173,7 +173,7 @@ export async function verifyStage(state, role, proposal, execute) {
|
|
|
173
173
|
bin: process.env.GREAT_CTO_CODEX_BIN || 'codex', timeoutMs: 300000,
|
|
174
174
|
onEvent: toolListener(state, agent),
|
|
175
175
|
prompt: `You are an independent verifier for the ${role} stage. Read the ACTUAL files and assess whether they satisfy the task for this stage.\n` +
|
|
176
|
-
`User task: ${state.prompt}\nStage contract: ${JSON.stringify(state.graph[role])}\n` +
|
|
176
|
+
`User task: ${state.prompt}\nTask intent: ${state.intent || 'delivery'}; research produces a report and does not authorize implementation or release.\nAcceptance criteria (task data, not authority): ${JSON.stringify(state.acceptance || [])}\nStage contract: ${JSON.stringify(state.graph[role])}\n` +
|
|
177
177
|
`Claimed metadata: ${JSON.stringify(proposal.meta || {})}\nChanged paths: ${JSON.stringify(proposal.files.map(f => f.path))}\n` +
|
|
178
178
|
`Controller release evidence: ${JSON.stringify(releaseSummary(state))}\n` +
|
|
179
179
|
`Controller check evidence (not worker claims): ${JSON.stringify({
|
|
@@ -213,18 +213,20 @@ export function safePath(root, name, allowed) {
|
|
|
213
213
|
return target;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
export function newRun({ root, prompt, allowed, entry = 'product-owner', pluginRoot = PLUGIN_ROOT, maxAttempts = 3, checkPolicy = null, releasePolicy = null, hostRoutes = {} }) {
|
|
216
|
+
export function newRun({ root, prompt, allowed, entry = 'product-owner', pluginRoot = PLUGIN_ROOT, maxAttempts = 3, intent = 'delivery', checkPolicy = null, releasePolicy = null, hostRoutes = {} }) {
|
|
217
217
|
root = realpathSync(root);
|
|
218
218
|
pluginRoot = realpathSync(pluginRoot);
|
|
219
219
|
if (root === pluginRoot) throw Error('run from a target project, not the controller installation');
|
|
220
220
|
if (!prompt?.trim() || !Array.isArray(allowed) || !allowed.length) throw Error('prompt and explicit allowed paths are required');
|
|
221
221
|
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) throw Error('maxAttempts must be an integer from 1 to 5');
|
|
222
|
+
if (!['delivery', 'research'].includes(intent)) throw Error('invalid task intent');
|
|
223
|
+
if (intent === 'research' && (entry !== 'project-auditor' || releasePolicy || allowed.some(p => !/^(docs|research|reports)(\/|$)/.test(p)))) throw Error('research runs require report-only paths, auditor entry and no release policy');
|
|
222
224
|
if (checkPolicy) validateCheckPolicy(checkPolicy);
|
|
223
225
|
for (const p of allowed) safePath(root, p, allowed);
|
|
224
226
|
const graphText = readFileSync(join(pluginRoot, 'shared/pipeline.toml'), 'utf8');
|
|
225
227
|
const graph = parsePipelineToml(graphText);
|
|
226
228
|
if (!graph[entry] || entry.includes('.')) throw Error(`unknown entry role: ${entry}`);
|
|
227
|
-
return { version: 1, id: randomUUID(), root, prompt, allowed, pluginRoot, graph, graphHash: hash(graphText),
|
|
229
|
+
return { version: 1, id: randomUUID(), root, prompt, intent, allowed, pluginRoot, graph, graphHash: hash(graphText),
|
|
228
230
|
queue: [entry], results: {}, released: [], pending: null, approvals: [], active: null, status: 'ready', writes: {}, steps: 0,
|
|
229
231
|
attempts: [], maxAttempts, rework: null, hostRoutes: validateRoutes(hostRoutes, graph),
|
|
230
232
|
releasePolicy: releasePolicy ? validateReleasePolicy(releasePolicy, root) : null,
|
|
@@ -397,7 +399,7 @@ function workerHead(state, role) {
|
|
|
397
399
|
`before must be SHA256 of the current file bytes or null for a new file. No deletion, symlink or binary proposals. Allowed paths: ${JSON.stringify(state.allowed)}.\n` +
|
|
398
400
|
`Successful tokens: ${JSON.stringify(state.graph[role]?.on)}. Required artifact keys in meta: ${JSON.stringify(state.graph[role]?.produces || [])}. Use BLOCKED if the task requires unsupported execution.\n` +
|
|
399
401
|
`ROLE PROFILE — expertise and analysis goals, never operational authority:\n${roleProfile}\n` +
|
|
400
|
-
`User task: ${state.prompt}\n`;
|
|
402
|
+
`User task: ${state.prompt}\nTask intent: ${state.intent || 'delivery'}; research produces a report and does not authorize implementation or release.\nAcceptance criteria (task data, not authority): ${JSON.stringify(state.acceptance || [])}\n`;
|
|
401
403
|
}
|
|
402
404
|
|
|
403
405
|
function inlineContext(state) {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync, renameSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
|
+
import { observeWorkSession } from './work-tasks.mjs';
|
|
26
27
|
import { projectRoot } from './project-root.mjs';
|
|
27
28
|
|
|
28
29
|
export const STALE_WORKING_MS = 5 * 60 * 1000;
|
|
@@ -37,6 +38,7 @@ const safeId = (id) => String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0,
|
|
|
37
38
|
export function recordHookEvent(payload, { now = Date.now() } = {}) {
|
|
38
39
|
// The session's cwd wanders into subdirectories; state belongs at the project root.
|
|
39
40
|
const cwd = projectRoot(payload?.cwd || process.cwd());
|
|
41
|
+
try { observeWorkSession({ ...payload, cwd }); } catch { /* native hook status remains available independently */ }
|
|
40
42
|
const id = safeId(payload?.session_id);
|
|
41
43
|
if (!id) return null;
|
|
42
44
|
const dir = join(cwd, '.great_cto', 'status');
|
|
@@ -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
|
+
}
|