great-cto 3.75.9 → 3.76.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/board/.claude-plugin/plugin.json +1 -1
- package/board/agents/architect.md +4 -0
- package/board/agents/ci-resolver.md +1 -0
- package/board/agents/design-advisor.md +28 -0
- package/board/agents/devops.md +1 -0
- package/board/agents/product-owner.md +13 -1
- package/board/agents/senior-dev.md +25 -9
- package/board/packages/board/lib/alerts.mjs +46 -1
- package/board/packages/board/lib/gate-run.sh +30 -0
- package/board/packages/board/lib/instant-alerts.mjs +159 -0
- package/board/packages/board/lib/merge-readiness.mjs +315 -0
- package/board/packages/board/lib/routes.mjs +29 -3
- package/board/packages/board/openapi.json +2996 -0
- package/board/packages/board/public/assets/icon-192.png +0 -0
- package/board/packages/board/public/assets/icon-512.png +0 -0
- package/board/packages/board/public/assets/icon-maskable-512.png +0 -0
- package/board/packages/board/public/assets/work-view.js +83 -3
- package/board/packages/board/public/index.html +112 -1
- package/board/packages/board/public/manifest.webmanifest +16 -0
- package/board/packages/board/public/sw.js +1 -1
- package/board/packages/board/route-coverage-baseline.json +25 -0
- package/board/packages/board/server.mjs +8 -1
- package/board/scripts/lib/gate-run-record.mjs +75 -0
- package/board/scripts/lib/handoff-state.mjs +478 -0
- package/board/scripts/lib/workspace.mjs +277 -0
- package/board/scripts/lib/worktree-state.mjs +128 -0
- package/board/shared/pipeline.toml +29 -0
- package/dist/release-activation.mjs +42 -10
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace — one task, one worktree (ADR-047, phase 1).
|
|
3
|
+
*
|
|
4
|
+
* This module decides; it does not act. It answers three questions and leaves
|
|
5
|
+
* the doing to the callers phase 3 adds:
|
|
6
|
+
*
|
|
7
|
+
* claimState which of five states a task's workspace is in
|
|
8
|
+
* archiveVerdict whether a worktree may be removed
|
|
9
|
+
* allocatePort which port a workspace gets (the one thing written here)
|
|
10
|
+
*
|
|
11
|
+
* The rule throughout: a read that failed is not an empty answer. A worktree
|
|
12
|
+
* listing git would not give is not "no worktrees", so it does not lead to
|
|
13
|
+
* creating one. A status git would not give is not "clean", so it does not lead
|
|
14
|
+
* to removing one. Uncommitted and untracked agent output is in no index and no
|
|
15
|
+
* history; a removal built on a wrong "clean" cannot be undone.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { worktreePath, integrationRef } from './worktree-state.mjs';
|
|
24
|
+
import { isLinkedWorktree, WORKSPACE_PORT_FIRST, WORKSPACE_PORT_LAST } from './handoff-state.mjs';
|
|
25
|
+
|
|
26
|
+
const OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] };
|
|
27
|
+
const PROVENANCE_FILE = 'great-cto-workspace.json';
|
|
28
|
+
const PROVENANCES = new Set(['created', 'adopted']);
|
|
29
|
+
const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/;
|
|
30
|
+
|
|
31
|
+
/** A task id is used in a path and a branch name, so it must be safe as both. */
|
|
32
|
+
export function validTaskId(id) {
|
|
33
|
+
return typeof id === 'string' && id.length <= 120 && TASK_ID.test(id) && !id.endsWith('.lock');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One git command in `dir`, as { ok, out, status }. `status` is the exit code; null after a timeout. */
|
|
37
|
+
export function gitIn(dir, exec) {
|
|
38
|
+
return (...args) => {
|
|
39
|
+
try {
|
|
40
|
+
return { ok: true, out: String(exec('git', ['-C', dir, ...args], OPTS)).trim(), status: 0 };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { ok: false, out: '', status: e.status ?? null };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function real(p) {
|
|
48
|
+
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The host that already made this workspace, or null. Whoever made it removes it. */
|
|
52
|
+
export function detectHost({ cwd, env = process.env, git, exists = fs.existsSync, commonRoot = null }) {
|
|
53
|
+
if (Object.keys(env).some((k) => k.startsWith('CONDUCTOR_'))) return { host: 'conductor', signal: 'CONDUCTOR_* in the environment' };
|
|
54
|
+
if (isLinkedWorktree(git)) {
|
|
55
|
+
// `.conductor/` alone proves nothing: a repository can commit it, and then the
|
|
56
|
+
// main checkout, shared with every session, would be adopted as a workspace.
|
|
57
|
+
if (commonRoot && exists(path.join(commonRoot, '.conductor'))) return { host: 'conductor', signal: '.conductor/ at the common root, in a linked worktree' };
|
|
58
|
+
return { host: 'linked-worktree', signal: 'git-dir differs from git-common-dir' };
|
|
59
|
+
}
|
|
60
|
+
if (`${cwd.split(path.sep).join('/')}/`.includes('/.claude/worktrees/')) return { host: 'claude-code', signal: 'path under .claude/worktrees' };
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseWorktreeList(text) {
|
|
65
|
+
return String(text).split('\n\n').filter(Boolean).map((block) => ({
|
|
66
|
+
path: /^worktree (.+)$/m.exec(block)?.[1] || null,
|
|
67
|
+
branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] || null,
|
|
68
|
+
})).filter((w) => w.path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Which of the five states a task's workspace is in. Creates nothing.
|
|
73
|
+
* { state: 'adopt' | 'reuse' | 'conflict' | 'create' | 'unavailable', ... }
|
|
74
|
+
*/
|
|
75
|
+
export function claimState({ taskId, cwd = process.cwd(), env = process.env, exec = execFileSync, exists = fs.existsSync }) {
|
|
76
|
+
const unavailable = (reason) => ({ state: 'unavailable', reason });
|
|
77
|
+
if (!validTaskId(taskId)) return unavailable('the task id cannot name a directory and a branch');
|
|
78
|
+
|
|
79
|
+
const git = gitIn(cwd, exec);
|
|
80
|
+
const common = git('rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
81
|
+
if (!common.ok) return unavailable('not a git repository');
|
|
82
|
+
if (path.basename(common.out) !== '.git') return unavailable('cannot name the main checkout of this repository');
|
|
83
|
+
const root = real(path.dirname(common.out));
|
|
84
|
+
|
|
85
|
+
const host = detectHost({ cwd, env, git, exists, commonRoot: root });
|
|
86
|
+
if (host) {
|
|
87
|
+
const top = git('rev-parse', '--show-toplevel');
|
|
88
|
+
if (!top.ok) return unavailable('git would not name the current worktree');
|
|
89
|
+
return { state: 'adopt', path: top.out, branch: git('branch', '--show-current').out || null, ...host };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const listed = git('worktree', 'list', '--porcelain');
|
|
93
|
+
if (!listed.ok) return unavailable('git worktree list failed, so what exists is not known');
|
|
94
|
+
const worktrees = parseWorktreeList(listed.out);
|
|
95
|
+
|
|
96
|
+
const target = worktreePath(root, taskId);
|
|
97
|
+
const branch = 'task/' + taskId;
|
|
98
|
+
const conflict = (reason) => ({ state: 'conflict', path: target, branch, reason });
|
|
99
|
+
const known = worktrees.find((w) => real(w.path) === target);
|
|
100
|
+
|
|
101
|
+
if (exists(target)) {
|
|
102
|
+
if (!known) return conflict('the directory exists but git does not know it');
|
|
103
|
+
if (known.branch !== branch) return conflict('the worktree is on ' + (known.branch || 'a detached HEAD') + ', not ' + branch);
|
|
104
|
+
return { state: 'reuse', path: target, branch };
|
|
105
|
+
}
|
|
106
|
+
if (known) return conflict('git lists this worktree but its directory is gone');
|
|
107
|
+
const elsewhere = worktrees.find((w) => w.branch === branch);
|
|
108
|
+
if (elsewhere) return conflict(branch + ' is already checked out at ' + elsewhere.path);
|
|
109
|
+
|
|
110
|
+
if (!git('rev-parse', '--verify', '--quiet', 'HEAD').ok) return unavailable('the repository has no commits');
|
|
111
|
+
const from = integrationRef((args) => git(...args).out);
|
|
112
|
+
if (!from) return unavailable('no integration branch to start from');
|
|
113
|
+
const branchExists = git('rev-parse', '--verify', '--quiet', 'refs/heads/' + branch).ok;
|
|
114
|
+
return { state: 'create', path: target, branch, from, branchExists };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function provenancePath(dir, git) {
|
|
118
|
+
const where = git('rev-parse', '--git-path', PROVENANCE_FILE);
|
|
119
|
+
return where.ok && where.out ? path.resolve(dir, where.out) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Records who made this worktree, in its private git directory: it never shows
|
|
124
|
+
* in `git status`, and it goes when the worktree goes.
|
|
125
|
+
*/
|
|
126
|
+
export function recordProvenance(dir, provenance, { taskId, exec = execFileSync, now = new Date() }) {
|
|
127
|
+
if (!PROVENANCES.has(provenance)) throw new Error('unknown provenance: ' + provenance);
|
|
128
|
+
const file = provenancePath(dir, gitIn(dir, exec));
|
|
129
|
+
if (!file) throw new Error('git would not name the worktree directory');
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify({ provenance, taskId, at: now.toISOString() }) + '\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The recorded provenance, or null when there is none or it cannot be read. */
|
|
134
|
+
export function readProvenance(dir, { exec = execFileSync } = {}) {
|
|
135
|
+
const file = provenancePath(dir, gitIn(dir, exec));
|
|
136
|
+
if (!file) return null;
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
139
|
+
return PROVENANCES.has(parsed?.provenance) ? parsed : null;
|
|
140
|
+
} catch { return null; } // archiveVerdict refuses on null, so no read here ever permits a removal
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* May this worktree be removed? Only when we created it, it has no uncommitted
|
|
145
|
+
* or untracked file, and its HEAD is on the integration branch. Every other
|
|
146
|
+
* answer, including every probe that could not answer, is { remove: false }.
|
|
147
|
+
*/
|
|
148
|
+
export function archiveVerdict(dir, { exec = execFileSync } = {}) {
|
|
149
|
+
const refuse = (reason) => ({ remove: false, reason });
|
|
150
|
+
if (readProvenance(dir, { exec })?.provenance !== 'created') return refuse('great_cto did not create this worktree');
|
|
151
|
+
|
|
152
|
+
const git = gitIn(dir, exec);
|
|
153
|
+
const status = git('status', '--porcelain');
|
|
154
|
+
if (!status.ok) return refuse('could not read the worktree status');
|
|
155
|
+
if (status.out) return refuse('the worktree has uncommitted or untracked files');
|
|
156
|
+
|
|
157
|
+
const ref = integrationRef((args) => git(...args).out);
|
|
158
|
+
if (!ref) return refuse('no integration branch to check against');
|
|
159
|
+
const merged = git('merge-base', '--is-ancestor', 'HEAD', ref);
|
|
160
|
+
if (merged.status === 1) return refuse('HEAD is not merged into ' + ref);
|
|
161
|
+
if (!merged.ok) return refuse('could not check whether HEAD is merged');
|
|
162
|
+
return { remove: true, ref };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The port a task tries first: the same one every time. */
|
|
166
|
+
export function preferredPort(taskId) {
|
|
167
|
+
const size = WORKSPACE_PORT_LAST - WORKSPACE_PORT_FIRST + 1;
|
|
168
|
+
return WORKSPACE_PORT_FIRST + (createHash('sha256').update(taskId).digest().readUInt32BE(0) % size);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pids listening on a port; [] when nobody is; null when lsof could not say. */
|
|
172
|
+
export function listeners(port, { exec = execFileSync } = {}) {
|
|
173
|
+
try {
|
|
174
|
+
const out = String(exec('lsof', ['-nP', '-iTCP:' + port, '-sTCP:LISTEN', '-t'], OPTS));
|
|
175
|
+
return out.split('\n').filter(Boolean).map(Number);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return e.status === 1 ? [] : null; // lsof exits 1 when it finds nothing
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function processAlive(pid) {
|
|
182
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
183
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function portsFile(env = process.env) {
|
|
187
|
+
return env.GREAT_CTO_PORTS_FILE || path.join(os.homedir(), '.great_cto', 'ports.json');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Claims in the file, or { refused } when the file exists and cannot be trusted. */
|
|
191
|
+
function readClaims(file) {
|
|
192
|
+
let text;
|
|
193
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
|
194
|
+
return e.code === 'ENOENT' ? { claims: [] } : { refused: 'could not read the file: ' + e.code };
|
|
195
|
+
}
|
|
196
|
+
let parsed;
|
|
197
|
+
try { parsed = JSON.parse(text); } catch (e) { return { refused: 'not valid JSON: ' + e.message }; }
|
|
198
|
+
if (!Array.isArray(parsed?.claims)) return { refused: 'no claims[] list' };
|
|
199
|
+
if (!parsed.claims.every((c) => c && typeof c.taskId === 'string' && Number.isInteger(c.port))) return { refused: 'a claim is malformed' };
|
|
200
|
+
return { claims: parsed.claims };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Gives a task its port and records the claim. The file says who claimed what;
|
|
205
|
+
* what is listening decides whether a port is actually free.
|
|
206
|
+
* { state: 'allocated' | 'reused' | 'refused' | 'exhausted' | 'failed', port?, file, reason? }
|
|
207
|
+
*/
|
|
208
|
+
export function allocatePort({
|
|
209
|
+
taskId, path: workspace, pid = process.pid, env = process.env, now = new Date(),
|
|
210
|
+
isAlive = processAlive, listening = (port) => listeners(port),
|
|
211
|
+
}) {
|
|
212
|
+
const file = portsFile(env);
|
|
213
|
+
const refused = (reason) => ({ state: 'refused', file, reason });
|
|
214
|
+
if (!validTaskId(taskId)) return refused('the task id is not valid');
|
|
215
|
+
const read = readClaims(file);
|
|
216
|
+
if (read.refused) return refused(read.refused);
|
|
217
|
+
|
|
218
|
+
const couldNotCheck = (port) => refused('could not check who is listening on port ' + port);
|
|
219
|
+
const own = read.claims.find((c) => c.taskId === taskId);
|
|
220
|
+
let port = null;
|
|
221
|
+
let state = 'allocated';
|
|
222
|
+
if (own) {
|
|
223
|
+
const held = isAlive(own.pid) ? [] : listening(own.port);
|
|
224
|
+
if (held === null) return couldNotCheck(own.port);
|
|
225
|
+
if (!held.length) { port = own.port; state = 'reused'; }
|
|
226
|
+
}
|
|
227
|
+
const dropped = new Set();
|
|
228
|
+
if (port === null) {
|
|
229
|
+
const size = WORKSPACE_PORT_LAST - WORKSPACE_PORT_FIRST + 1;
|
|
230
|
+
const start = preferredPort(taskId) - WORKSPACE_PORT_FIRST;
|
|
231
|
+
for (let i = 0; i < size && port === null; i += 1) {
|
|
232
|
+
const candidate = WORKSPACE_PORT_FIRST + ((start + i) % size);
|
|
233
|
+
const other = read.claims.find((c) => c.port === candidate && c.taskId !== taskId);
|
|
234
|
+
if (other && isAlive(other.pid)) continue;
|
|
235
|
+
const held = listening(candidate);
|
|
236
|
+
if (held === null) return couldNotCheck(candidate);
|
|
237
|
+
if (held.length) continue;
|
|
238
|
+
if (other) dropped.add(other.taskId);
|
|
239
|
+
port = candidate;
|
|
240
|
+
}
|
|
241
|
+
if (port === null) return { state: 'exhausted', file };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const claims = read.claims.filter((c) => c.taskId !== taskId && !dropped.has(c.taskId));
|
|
245
|
+
claims.push({ taskId, port, path: workspace, pid, at: now.toISOString() });
|
|
246
|
+
const failed = writeClaims(file, claims);
|
|
247
|
+
return failed ? { state: 'failed', file, reason: failed } : { state, port, file };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Write-then-rename: a crash mid-write must not leave a truncated file that reads as "nothing allocated". */
|
|
251
|
+
function writeClaims(file, claims) {
|
|
252
|
+
try {
|
|
253
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
254
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
255
|
+
fs.writeFileSync(tmp, JSON.stringify({ claims }, null, 2) + '\n');
|
|
256
|
+
fs.renameSync(tmp, file);
|
|
257
|
+
return null;
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return e.message;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Gives a task's port back. A ports file that cannot be trusted is left as it is. */
|
|
264
|
+
export function releasePort(taskId, { env = process.env } = {}) {
|
|
265
|
+
const file = portsFile(env);
|
|
266
|
+
const read = readClaims(file);
|
|
267
|
+
if (read.refused) return { state: 'refused', file, reason: read.refused };
|
|
268
|
+
const claims = read.claims.filter((c) => c.taskId !== taskId);
|
|
269
|
+
if (claims.length === read.claims.length) return { state: 'none', file };
|
|
270
|
+
const failed = writeClaims(file, claims);
|
|
271
|
+
return failed ? { state: 'failed', file, reason: failed } : { state: 'released', file };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The environment a workspace's servers get: one new name, and the one the board already honours. */
|
|
275
|
+
export function workspaceEnv(port) {
|
|
276
|
+
return { GREAT_CTO_WORKSPACE_PORT: String(port), BOARD_PORT: String(port) };
|
|
277
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* worktree-state — is there work sitting in a worktree that nobody landed?
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ---------------
|
|
7
|
+
* On 2026-08-07 senior-dev implemented a library in a git worktree: 381 lines
|
|
8
|
+
* plus 394 lines of tests, thirty passing. Its verdict went to the worktree's
|
|
9
|
+
* own `.great_cto/` too, so the main tree saw an empty log and the pipeline
|
|
10
|
+
* read "no verdict recorded". The work was found because a `cp` happened to say
|
|
11
|
+
* "files are identical" — luck, not a mechanism. Worktrees are removed when the
|
|
12
|
+
* agent is done with them.
|
|
13
|
+
*
|
|
14
|
+
* Nothing anywhere notices. The main tree looks clean, which reads as "the agent
|
|
15
|
+
* produced nothing" rather than "the agent produced something you cannot see",
|
|
16
|
+
* and those are the same picture from the pipeline's side.
|
|
17
|
+
*
|
|
18
|
+
* This reports; it does not block. A worktree with changes is the normal state
|
|
19
|
+
* while an agent is working, and several may be live at once during a parallel
|
|
20
|
+
* fan-out. The failure is silence at the moment one stops, not the existence of
|
|
21
|
+
* uncommitted work.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
/** Where Claude Code puts agent worktrees, relative to the repo root. */
|
|
29
|
+
export const WORKTREE_DIR = join('.claude', 'worktrees');
|
|
30
|
+
|
|
31
|
+
/** Where one named worktree lives. The single reading of this path (ADR-047). */
|
|
32
|
+
export function worktreePath(root, name = '') {
|
|
33
|
+
return join(root, WORKTREE_DIR, name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Changes in one worktree, or null when it is clean or unreadable. */
|
|
37
|
+
export function worktreeChanges(dir, { exec = execFileSync } = {}) {
|
|
38
|
+
let out;
|
|
39
|
+
try {
|
|
40
|
+
out = exec('git', ['-C', dir, 'status', '--porcelain'],
|
|
41
|
+
{ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
|
+
} catch {
|
|
43
|
+
// Not a git worktree, or git refused. A directory we cannot ask about is not
|
|
44
|
+
// evidence of work — say nothing rather than guess.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const lines = String(out).split('\n').map((l) => l.trim()).filter(Boolean);
|
|
48
|
+
return lines.length ? lines : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The branch work lands on, as a full ref, or null when none can be named. */
|
|
52
|
+
export function integrationRef(git) {
|
|
53
|
+
const head = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
54
|
+
if (head) return head;
|
|
55
|
+
for (const ref of ['refs/remotes/origin/main', 'refs/remotes/origin/master', 'refs/heads/main', 'refs/heads/master']) {
|
|
56
|
+
if (git(['rev-parse', '--verify', '--quiet', ref])) return ref;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Commits in a worktree that the integration branch does not have, as
|
|
63
|
+
* { count, ref }, or null when there are none or git cannot say. A clean status
|
|
64
|
+
* proves nothing here: committed work leaves `git status` empty.
|
|
65
|
+
*/
|
|
66
|
+
export function unmergedCommits(dir, { exec = execFileSync } = {}) {
|
|
67
|
+
const opts = { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] };
|
|
68
|
+
const git = (args) => {
|
|
69
|
+
try { return String(exec('git', ['-C', dir, ...args], opts)).trim(); } catch { return ''; }
|
|
70
|
+
};
|
|
71
|
+
const ref = integrationRef(git);
|
|
72
|
+
if (!ref) return null;
|
|
73
|
+
try {
|
|
74
|
+
exec('git', ['-C', dir, 'merge-base', '--is-ancestor', 'HEAD', ref], opts);
|
|
75
|
+
return null; // landed
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Exit 1 is the answer "not an ancestor"; anything else is git failing to answer.
|
|
78
|
+
if (e.status !== 1) return null;
|
|
79
|
+
}
|
|
80
|
+
const count = Number(git(['rev-list', '--count', `${ref}..HEAD`]));
|
|
81
|
+
return count > 0 ? { count, ref: ref.replace(/^refs\/(remotes\/|heads\/)/, '') } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Worktrees holding unlanded changes: uncommitted, or committed and not merged.
|
|
86
|
+
*
|
|
87
|
+
* `maxAgeMs` bounds it to worktrees touched recently: an abandoned one from last
|
|
88
|
+
* week is a cleanup task, not a stage that just stopped, and reporting it on
|
|
89
|
+
* every subagent stop would train people to ignore the message.
|
|
90
|
+
*/
|
|
91
|
+
export function worktreesWithChanges(root = process.cwd(), { maxAgeMs = 6 * 60 * 60 * 1000, now = Date.now(), exec } = {}) {
|
|
92
|
+
const base = worktreePath(root);
|
|
93
|
+
if (!existsSync(base)) return [];
|
|
94
|
+
let names;
|
|
95
|
+
try { names = readdirSync(base); } catch { return []; }
|
|
96
|
+
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const dir = join(base, name);
|
|
100
|
+
let mtime;
|
|
101
|
+
try {
|
|
102
|
+
const st = statSync(dir);
|
|
103
|
+
if (!st.isDirectory()) continue;
|
|
104
|
+
mtime = st.mtimeMs;
|
|
105
|
+
} catch { continue; }
|
|
106
|
+
if (now - mtime > maxAgeMs) continue;
|
|
107
|
+
const changes = worktreeChanges(dir, { exec });
|
|
108
|
+
const unmerged = unmergedCommits(dir, { exec });
|
|
109
|
+
if (changes || unmerged) out.push({ name, dir, changes: changes || [], unmerged, ageMs: now - mtime });
|
|
110
|
+
}
|
|
111
|
+
return out.sort((a, b) => a.ageMs - b.ageMs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One operator-facing line, or null when there is nothing unlanded. */
|
|
115
|
+
export function explainWorktrees(list) {
|
|
116
|
+
if (!list || !list.length) return null;
|
|
117
|
+
const parts = list.map((w) => {
|
|
118
|
+
const shown = w.changes.slice(0, 5).map((c) => c.replace(/^\S+\s+/, ''));
|
|
119
|
+
const more = w.changes.length > shown.length ? ` (+${w.changes.length - shown.length} more)` : '';
|
|
120
|
+
const held = [];
|
|
121
|
+
if (shown.length) held.push(`${shown.join(', ')}${more}`);
|
|
122
|
+
if (w.unmerged) held.push(`${w.unmerged.count} commit${w.unmerged.count === 1 ? '' : 's'} not on ${w.unmerged.ref}`);
|
|
123
|
+
return `${w.dir}: ${held.join('; ')}`;
|
|
124
|
+
});
|
|
125
|
+
return `work is sitting in a worktree and has not been landed — ${parts.join(' | ')}. `
|
|
126
|
+
+ 'The main tree looks clean, which reads as "the agent produced nothing" rather than '
|
|
127
|
+
+ '"the agent produced something you cannot see". Land it or say why not; worktrees are removed when the agent is done with them.';
|
|
128
|
+
}
|
|
@@ -93,6 +93,35 @@ on = ["NO_BUILD"]
|
|
|
93
93
|
gate = "gate:product"
|
|
94
94
|
next = []
|
|
95
95
|
|
|
96
|
+
# A UI feature reaches design-advisor from here. The default edge above goes to
|
|
97
|
+
# pm, and nothing else named design-advisor: it ran only when the orchestrator
|
|
98
|
+
# happened to read the routing table, so "after architect" was a sentence and not
|
|
99
|
+
# an edge. The stage cannot sit on the default edge, because a backend feature
|
|
100
|
+
# would then wait for a design that never starts. The architect knows which case
|
|
101
|
+
# this is, so its verdict picks the branch. No depth skip here: a contract exists
|
|
102
|
+
# to be planned against, so pm runs after it at any depth.
|
|
103
|
+
[transitions.architect.APPROVED_UI]
|
|
104
|
+
on = ["APPROVED_UI"]
|
|
105
|
+
produces = ["arch"]
|
|
106
|
+
gate = "gate:arch"
|
|
107
|
+
next = ["design-advisor"]
|
|
108
|
+
|
|
109
|
+
# Draft screens before the CTO decides WHAT. product-owner records this instead of
|
|
110
|
+
# raising gate:product, and the gate moves to the end of the draft: a person
|
|
111
|
+
# approving a product should see what they are approving. No gate on this edge is
|
|
112
|
+
# deliberate. It removes no check, it only moves the same one a stage later.
|
|
113
|
+
[transitions.product-owner.SCREENS_NEEDED]
|
|
114
|
+
on = ["SCREENS_NEEDED"]
|
|
115
|
+
produces = ["brief"]
|
|
116
|
+
next = ["design-advisor"]
|
|
117
|
+
|
|
118
|
+
[transitions.design-advisor.SCREENS_DRAFT]
|
|
119
|
+
on = ["SCREENS_DRAFT"]
|
|
120
|
+
# docs/design/SCREENS-{slug}.html — throwaway drafts of the main scenario
|
|
121
|
+
produces = ["screens"]
|
|
122
|
+
gate = "gate:product"
|
|
123
|
+
next = ["architect"]
|
|
124
|
+
|
|
96
125
|
[transitions.pm]
|
|
97
126
|
on = ["PLAN_READY", "DONE"]
|
|
98
127
|
# PLAN-{slug}.md, and one IMPL-BRIEF per senior-dev task (Step 7b).
|
|
@@ -200,6 +200,29 @@ function expectedCandidate(files) {
|
|
|
200
200
|
const inventory = files.map(file => ({ path: file.path, size: file.bytes.length, sha256: hash(file.bytes), mode: file.mode })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
201
201
|
return hash(JSON.stringify(inventory));
|
|
202
202
|
}
|
|
203
|
+
/** What npm wrote against what was admitted: names the first difference, keeps no bytes. */
|
|
204
|
+
function candidateDifference(files, packageRoot) {
|
|
205
|
+
const actual = new Map(packageSnapshot(packageRoot).files.map(file => [file.path, file]));
|
|
206
|
+
const differing = files.filter(file => { const found = actual.get(file.path); return !found || found.sha256 !== hash(file.bytes) || found.mode !== file.mode; });
|
|
207
|
+
const first = differing[0], found = first && actual.get(first.path);
|
|
208
|
+
return { differing: differing.length, unexpected: [...actual.keys()].filter(name => !files.some(file => file.path === name)).length, ...(first ? { path: first.path, expectedMode: first.mode.toString(8), actualMode: found ? found.mode.toString(8) : null, sameBytes: !!found && found.sha256 === hash(first.bytes) } : {}) };
|
|
209
|
+
}
|
|
210
|
+
/** The result of a failed step, bounded: the journal explains the rollback and stays small. */
|
|
211
|
+
function stepFailure(step, result) {
|
|
212
|
+
return { step, status: result?.status ?? null, signal: result?.signal ?? null, error: result?.error?.code || result?.error?.message || null,
|
|
213
|
+
stdout: String(result?.stdout || '').slice(-500), stderr: String(result?.stderr || '').slice(-2000) };
|
|
214
|
+
}
|
|
215
|
+
/** npm applies the caller's umask to the files it unpacks, and the admitted inventory
|
|
216
|
+
* includes modes. A private umask must not turn identical bytes into a failed activation. */
|
|
217
|
+
function withPackageUmask(action) {
|
|
218
|
+
const previous = process.umask(0o022);
|
|
219
|
+
try {
|
|
220
|
+
return action();
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
process.umask(previous);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
203
226
|
function privateInstallationState(stateRoot, packageRoot, { recovering = false } = {}) {
|
|
204
227
|
const root = canonicalDirectory(stateRoot, { create: true, privateMode: true });
|
|
205
228
|
const installationId = hash(packageRoot), dir = path.join(root, installationId);
|
|
@@ -303,9 +326,13 @@ function validJournal(journal, installation, state) {
|
|
|
303
326
|
fail('invalid-recovery-selection');
|
|
304
327
|
return journal;
|
|
305
328
|
}
|
|
306
|
-
function rollback(installation, state, journal, context) {
|
|
329
|
+
function rollback(installation, state, journal, context, reason = null, detail = null) {
|
|
307
330
|
try {
|
|
308
331
|
validJournal(journal, installation, state);
|
|
332
|
+
if (reason) {
|
|
333
|
+
journal.failure = reason;
|
|
334
|
+
journal.failureDetail = detail;
|
|
335
|
+
}
|
|
309
336
|
const backup = path.join(state.dir, 'transactions', journal.transactionId, 'previous/package');
|
|
310
337
|
const snapshot = packageSnapshot(backup);
|
|
311
338
|
if (snapshot.digest !== journal.previous.packageDigest)
|
|
@@ -314,7 +341,7 @@ function rollback(installation, state, journal, context) {
|
|
|
314
341
|
if (stateMatches(current, journal.previous)) {
|
|
315
342
|
journal.state = 'ROLLED_BACK';
|
|
316
343
|
writeRecord(state.journalPath, journal);
|
|
317
|
-
return outcome('ROLLED_BACK', context);
|
|
344
|
+
return outcome('ROLLED_BACK', context, reason || journal.failure);
|
|
318
345
|
}
|
|
319
346
|
if (!journal.written || !stateMatches(current, journal.written))
|
|
320
347
|
fail('recovery-target-drift-or-unobserved-write');
|
|
@@ -358,7 +385,7 @@ function rollback(installation, state, journal, context) {
|
|
|
358
385
|
journal.state = 'ROLLED_BACK';
|
|
359
386
|
journal.recovery = 'RESTORED_PREEXISTING_OPERATOR_STATE';
|
|
360
387
|
writeRecord(state.journalPath, journal);
|
|
361
|
-
return outcome('ROLLED_BACK', context);
|
|
388
|
+
return outcome('ROLLED_BACK', context, reason || journal.failure);
|
|
362
389
|
}
|
|
363
390
|
catch (error) {
|
|
364
391
|
journal.state = 'RECOVERY_REQUIRED';
|
|
@@ -467,7 +494,7 @@ export function activateNpmRelease(admission, options) {
|
|
|
467
494
|
if (finalArtifact.sha256 !== artifact.sha256 || hash(readRegular(activationArchive, { max: 128 * 1024 * 1024 })) !== artifact.sha256)
|
|
468
495
|
fail('artifact-changed-at-activation');
|
|
469
496
|
const args = ['install', '--global', activationArchive, '--ignore-scripts', '--offline', '--prefix', installation.prefix, '--package-lock=false', '--no-save', '--audit=false', '--fund=false'];
|
|
470
|
-
const result = run(npmPath, args, { cwd: tx, env, encoding: 'utf8', timeout: 180000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
497
|
+
const result = withPackageUmask(() => run(npmPath, args, { cwd: tx, env, encoding: 'utf8', timeout: 180000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
471
498
|
try {
|
|
472
499
|
journal.written = targetState(installation);
|
|
473
500
|
}
|
|
@@ -476,15 +503,20 @@ export function activateNpmRelease(admission, options) {
|
|
|
476
503
|
}
|
|
477
504
|
phase('ACTIVE');
|
|
478
505
|
if (result?.error || result?.signal || result?.status !== 0)
|
|
479
|
-
return rollback(installation, state, journal, context);
|
|
480
|
-
if (!journal.written
|
|
481
|
-
return rollback(installation, state, journal, context);
|
|
506
|
+
return rollback(installation, state, journal, context, 'npm-install-failed', stepFailure('npm-install', result));
|
|
507
|
+
if (!journal.written)
|
|
508
|
+
return rollback(installation, state, journal, context, 'installed-package-unreadable');
|
|
509
|
+
if (journal.written.filesDigest !== expectedCandidate(payload.files))
|
|
510
|
+
return rollback(installation, state, journal, context, 'installed-files-differ-from-admitted', candidateDifference(payload.files, installation.packageRoot));
|
|
482
511
|
if (!stateMatches(targetState(installation), journal.written))
|
|
483
512
|
return outcome('RECOVERY_REQUIRED', context, 'target-drift-after-activation');
|
|
484
513
|
for (const smokeArgs of [[installation.binaryPath, '--version'], [installation.binaryPath, 'codex-host', 'list']]) {
|
|
485
514
|
const smoke = run(process.execPath, smokeArgs, { cwd: tx, env, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
486
|
-
|
|
487
|
-
|
|
515
|
+
const step = 'smoke-' + (smokeArgs.includes('--version') ? 'version' : 'codex-host-list');
|
|
516
|
+
if (smoke?.error || smoke?.signal || smoke?.status !== 0)
|
|
517
|
+
return rollback(installation, state, journal, context, step + '-failed', stepFailure(step, smoke));
|
|
518
|
+
if (smokeArgs.includes('--version') && String(smoke.stdout || '').trim() !== info.version)
|
|
519
|
+
return rollback(installation, state, journal, context, 'smoke-version-mismatch', stepFailure(step, smoke));
|
|
488
520
|
if (!stateMatches(targetState(installation), journal.written))
|
|
489
521
|
return outcome('RECOVERY_REQUIRED', context, 'target-drift-during-smoke');
|
|
490
522
|
}
|
|
@@ -496,7 +528,7 @@ export function activateNpmRelease(admission, options) {
|
|
|
496
528
|
if (journal && error.activationCrash)
|
|
497
529
|
return outcome('RECOVERY_REQUIRED', context, 'interrupted-activation');
|
|
498
530
|
if (journal && ['ACTIVATING', 'ACTIVE', 'RECOVERY_REQUIRED'].includes(journal.state)) {
|
|
499
|
-
return rollback(installation, state, journal, context);
|
|
531
|
+
return rollback(installation, state, journal, context, error.activationReason || 'activation-check-failed');
|
|
500
532
|
}
|
|
501
533
|
if (journal) {
|
|
502
534
|
journal.state = 'REFUSED';
|
package/package.json
CHANGED