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.
Files changed (29) hide show
  1. package/board/.claude-plugin/plugin.json +1 -1
  2. package/board/agents/architect.md +4 -0
  3. package/board/agents/ci-resolver.md +1 -0
  4. package/board/agents/design-advisor.md +28 -0
  5. package/board/agents/devops.md +1 -0
  6. package/board/agents/product-owner.md +13 -1
  7. package/board/agents/senior-dev.md +25 -9
  8. package/board/packages/board/lib/alerts.mjs +46 -1
  9. package/board/packages/board/lib/gate-run.sh +30 -0
  10. package/board/packages/board/lib/instant-alerts.mjs +159 -0
  11. package/board/packages/board/lib/merge-readiness.mjs +315 -0
  12. package/board/packages/board/lib/routes.mjs +29 -3
  13. package/board/packages/board/openapi.json +2996 -0
  14. package/board/packages/board/public/assets/icon-192.png +0 -0
  15. package/board/packages/board/public/assets/icon-512.png +0 -0
  16. package/board/packages/board/public/assets/icon-maskable-512.png +0 -0
  17. package/board/packages/board/public/assets/work-view.js +83 -3
  18. package/board/packages/board/public/index.html +112 -1
  19. package/board/packages/board/public/manifest.webmanifest +16 -0
  20. package/board/packages/board/public/sw.js +1 -1
  21. package/board/packages/board/route-coverage-baseline.json +25 -0
  22. package/board/packages/board/server.mjs +8 -1
  23. package/board/scripts/lib/gate-run-record.mjs +75 -0
  24. package/board/scripts/lib/handoff-state.mjs +478 -0
  25. package/board/scripts/lib/workspace.mjs +277 -0
  26. package/board/scripts/lib/worktree-state.mjs +128 -0
  27. package/board/shared/pipeline.toml +29 -0
  28. package/dist/release-activation.mjs +42 -10
  29. 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 || journal.written.filesDigest !== expectedCandidate(payload.files))
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
- if (smoke?.error || smoke?.signal || smoke?.status !== 0 || (smokeArgs.includes('--version') && String(smoke.stdout || '').trim() !== info.version))
487
- return rollback(installation, state, journal, context);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.75.9",
3
+ "version": "3.76.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",