great-cto 3.31.0 → 3.33.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.31.0",
4
+ "version": "3.33.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -251,6 +251,11 @@
251
251
  "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/session-end.mjs\" 2>/dev/null; true",
252
252
  "timeout": 8,
253
253
  "statusMessage": "Saving session summary..."
254
+ },
255
+ {
256
+ "type": "command",
257
+ "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/session-status.mjs\" 2>/dev/null; true",
258
+ "timeout": 5
254
259
  }
255
260
  ]
256
261
  }
@@ -273,6 +278,11 @@
273
278
  "type": "command",
274
279
  "command": "[ -n \"$GREAT_CTO_CLASS_TELEMETRY\" ] && { PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/classify-telemetry.mjs\" 2>/dev/null; }; true",
275
280
  "timeout": 3
281
+ },
282
+ {
283
+ "type": "command",
284
+ "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/session-status.mjs\" 2>/dev/null; true",
285
+ "timeout": 5
276
286
  }
277
287
  ]
278
288
  }
@@ -309,6 +319,23 @@
309
319
  "type": "command",
310
320
  "async": true,
311
321
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/register-project.mjs\" 2>/dev/null || true"
322
+ },
323
+ {
324
+ "type": "command",
325
+ "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/session-status.mjs\" 2>/dev/null; true",
326
+ "timeout": 5
327
+ }
328
+ ]
329
+ }
330
+ ],
331
+ "Notification": [
332
+ {
333
+ "matcher": "",
334
+ "hooks": [
335
+ {
336
+ "type": "command",
337
+ "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/session-status.mjs\" 2>/dev/null; true",
338
+ "timeout": 5
312
339
  }
313
340
  ]
314
341
  }
@@ -14,6 +14,7 @@ import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInf
14
14
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
15
  import { agentUsage, usageSnapshot } from '../../../scripts/lib/agent-usage.mjs';
16
16
  import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
17
+ import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
17
18
  import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
18
19
  import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as verifyRouterKey } from '../../../scripts/lib/router-key.mjs';
19
20
 
@@ -1470,6 +1471,15 @@ async function dispatch(req, res, url, cwd) {
1470
1471
 
1471
1472
  // Domain reviewers this project's PROJECT.md requires (archetype, packs,
1472
1473
  // compliance), each with whether a verdict exists — the list gate:ship refuses on.
1474
+ // Sessions in this project that wait for a person — written by the
1475
+ // Notification / Stop / UserPromptSubmit hooks (scripts/lib/session-status.mjs).
1476
+ if (pathname === '/api/session-status') {
1477
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
1478
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1479
+ res.end(JSON.stringify({ sessions: c ? readSessionStatus(c) : [] }));
1480
+ return true;
1481
+ }
1482
+
1473
1483
  if (pathname === '/api/required-reviewers') {
1474
1484
  const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
1475
1485
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@@ -10,6 +10,7 @@
10
10
  * cost_summary — LLM spend, budget burn, top features
11
11
  * pipeline_stages — detailed stage list with verdicts
12
12
  * recent_verdicts — last N agent verdicts (default 10)
13
+ * wait_for_board_change — block until a verdict lands or a session is blocked
13
14
  *
14
15
  * Usage:
15
16
  * # Start as MCP server (board must be running on --port)
@@ -31,6 +32,7 @@
31
32
  */
32
33
 
33
34
  import { createInterface } from 'node:readline';
35
+ import { waitForChange } from '../../scripts/lib/board-watch.mjs';
34
36
 
35
37
  const PORT = parseInt(process.env.GREAT_CTO_PORT || '3141', 10);
36
38
  const BASE_URL = `http://127.0.0.1:${PORT}`;
@@ -115,6 +117,23 @@ const TOOLS = [
115
117
  },
116
118
  },
117
119
  },
120
+ {
121
+ name: 'wait_for_board_change',
122
+ description:
123
+ 'Blocks until something in this project needs you — an agent wrote a verdict ' +
124
+ '(BLOCKED/FAIL first), or a session stopped on a permission prompt — then returns ' +
125
+ 'only those changes. Use it instead of polling status in a loop while parallel ' +
126
+ 'work runs: one call, not a sleep-and-list per turn. Call first with no `since` ' +
127
+ 'to get a cursor; pass the returned `cursor` back each time so nothing between ' +
128
+ 'calls is missed. Reads the project files directly; the board need not be running.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ since: { type: 'string', description: 'Cursor from the previous call. Omit on the first call.' },
133
+ timeout_s: { type: 'number', description: 'Longest wait in seconds (1–600). Default: 300.' },
134
+ },
135
+ },
136
+ },
118
137
  ];
119
138
 
120
139
  // ── HTTP helpers ───────────────────────────────────────────────────────────
@@ -245,6 +264,12 @@ async function callTool(name, args = {}) {
245
264
  return lines.join('\n');
246
265
  }
247
266
 
267
+ if (name === 'wait_for_board_change') {
268
+ // The MCP server runs in the project directory (codex-host starts it there).
269
+ const r = await waitForChange({ cwd: process.cwd(), since: args.since || null, timeoutS: args.timeout_s || 300 });
270
+ return JSON.stringify(r);
271
+ }
272
+
248
273
  throw new Error(`Unknown tool: ${name}`);
249
274
  }
250
275
 
@@ -1696,6 +1696,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1696
1696
  .pl-stage.pl-compact .pl-name { color: var(--text2); }
1697
1697
  .pl-stage.active { flex: 2; }
1698
1698
  /* Idle-focus all-clear card (shown when nothing needs a decision). */
1699
+ /* A session stopped on a permission prompt or an unanswered wait — the one piece
1700
+ of session status the operator can act on, so it is styled like a decision. */
1701
+ .session-blocked {
1702
+ padding: 6px 10px;
1703
+ margin-bottom: 4px;
1704
+ border-left: 3px solid var(--status-blocked);
1705
+ background: var(--bg-elevated);
1706
+ }
1707
+
1699
1708
  .inbox-allclear {
1700
1709
  display: flex; align-items: center; gap: 14px;
1701
1710
  padding: 22px 22px; margin-top: 4px;
@@ -3399,6 +3408,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3399
3408
  two speaking states render into #inbox-receipt-foot at the bottom of
3400
3409
  this page (see refreshReceipt). -->
3401
3410
  <div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:var(--fs-small)"></div>
3411
+ <!-- Sessions stopped on the operator: a permission prompt, or idle waiting for
3412
+ an answer (Notification hook → .great_cto/status/). The one piece of
3413
+ status that IS a decision, so it sits above the status disclosure. -->
3414
+ <div id="inbox-sessions" style="display:none;margin:6px 0 10px;font-size:var(--fs-small)"></div>
3402
3415
  <!-- Everything below is STATUS, and status is not a decision.
3403
3416
 
3404
3417
  Six of the seven blocks on this screen were read-only: a pipeline rail,
@@ -4673,7 +4686,28 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4673
4686
  }).join('');
4674
4687
  }
4675
4688
 
4689
+ // Sessions waiting on a person (scripts/lib/session-status.mjs). `blocked` is a
4690
+ // prompt or an unanswered wait and gets a row; `waiting` (turn ended) is what every
4691
+ // open session is between turns, so it is one muted count, not a row each.
4692
+ async function refreshSessionStatus() {
4693
+ const el = document.getElementById('inbox-sessions');
4694
+ if (!el) return;
4695
+ const r = await api(`/api/session-status${pqs()}`);
4696
+ const rows = (r && Array.isArray(r.sessions)) ? r.sessions : [];
4697
+ const blocked = rows.filter((x) => x.state === 'blocked');
4698
+ const waiting = rows.filter((x) => x.state === 'waiting').length;
4699
+ if (!blocked.length && !waiting) { el.style.display = 'none'; el.innerHTML = ''; return; }
4700
+ el.style.display = '';
4701
+ el.innerHTML = blocked.map((x) => `<div class="session-blocked">
4702
+ <strong>⏸ session ${esc(String(x.session).slice(0, 8))} waits for you</strong>
4703
+ <span class="muted">· ${esc(agoStr(x.since))}${x.kind ? ` · ${esc(x.kind)}` : ''}</span><br>
4704
+ <span>${esc(x.reason || 'waiting for input')}</span>
4705
+ </div>`).join('')
4706
+ + (waiting ? `<div class="muted" title="these sessions finished their turn; the next message is yours">${waiting} session${waiting === 1 ? '' : 's'} finished a turn and wait for your next message</div>` : '');
4707
+ }
4708
+
4676
4709
  async function refreshInbox() {
4710
+ refreshSessionStatus();
4677
4711
  // Tiers first: the gate rows are rendered from this same call, and a row drawn
4678
4712
  // before the tiers arrive would silently claim every gate is waiting.
4679
4713
  await refreshGateTiers();
@@ -134,6 +134,9 @@ const TOOL_POSTURES = Object.freeze({
134
134
  WebSearch: ['network.fetch', 'communication.external.send'],
135
135
  Agent: ['process.spawn'],
136
136
  Task: ['process.spawn'],
137
+ // Loads a skill's text into the agent's context. It grants no tool the agent does
138
+ // not already hold: the skill runs with the agent's own grants.
139
+ Skill: ['code.read'],
137
140
  });
138
141
 
139
142
  /** MCP and beta tools, matched by prefix — the list of these grows monthly. */
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * board-watch — wait until something in this project needs the caller, then say
4
+ * what. One blocking call instead of a polling loop.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * An orchestrator waiting on parallel work (coordinator, codex-host) has no way to
9
+ * wait: it sleeps, lists, sleeps, lists, and every round trip is a turn. On the
10
+ * projects measured on 2026-09-21, 69 agent runs ended at their turn cap.
11
+ * fynnfluegge/agtx (Apache-2.0) counted the same cost in one 14-task run — 73
12
+ * sleeps and 96 listings — and replaced it with `wait_for_board_change`: block,
13
+ * and return only the changes that need the caller. Written here, from files the
14
+ * project already has, so it needs no board process:
15
+ *
16
+ * verdict an agent wrote a new verdict (its value and kind: negative first)
17
+ * blocked a session stopped on a permission prompt or an unanswered wait
18
+ *
19
+ * The cursor is the snapshot the caller last saw, so a change that happens between
20
+ * two calls is never missed: pass back the `cursor` from the previous answer.
21
+ *
22
+ * Usage:
23
+ * node scripts/lib/board-watch.mjs [--since <cursor>] [--timeout 300] [--cwd DIR]
24
+ * → one JSON line: { cursor, changes: [...], timedOut }
25
+ * With no --since it answers at once with the current cursor and nothing else.
26
+ */
27
+ import { fileURLToPath } from 'node:url';
28
+ import { latestVerdicts } from './ship-evidence.mjs';
29
+ import { readSessionStatus } from './session-status.mjs';
30
+
31
+ export const MAX_TIMEOUT_S = 600;
32
+
33
+ /** What the caller could need to react to, reduced to comparable keys. */
34
+ export function snapshot(cwd, { now = Date.now() } = {}) {
35
+ const verdicts = {};
36
+ for (const [agent, v] of latestVerdicts(cwd)) verdicts[agent] = { verdict: v.verdict, ts: v.ts, kind: v.kind };
37
+ const blocked = {};
38
+ for (const s of readSessionStatus(cwd, { now })) {
39
+ if (s.state === 'blocked') blocked[s.session] = { since: s.since, reason: s.reason || '' };
40
+ }
41
+ return { verdicts, blocked };
42
+ }
43
+
44
+ /** Changes from `prev` to `next` that need the caller, most urgent first. */
45
+ export function diffSnapshots(prev, next) {
46
+ const out = [];
47
+ for (const [agent, v] of Object.entries(next.verdicts || {})) {
48
+ const p = prev.verdicts?.[agent];
49
+ if (!p || p.ts !== v.ts || p.verdict !== v.verdict) out.push({ type: 'verdict', agent, verdict: v.verdict, kind: v.kind, ts: v.ts });
50
+ }
51
+ for (const [session, b] of Object.entries(next.blocked || {})) {
52
+ const p = prev.blocked?.[session];
53
+ if (!p || p.since !== b.since) out.push({ type: 'blocked', session, reason: b.reason, since: b.since });
54
+ }
55
+ const rank = (c) => (c.type === 'blocked' ? 0 : c.kind === 'negative' ? 1 : 2);
56
+ return out.sort((a, b) => rank(a) - rank(b));
57
+ }
58
+
59
+ export const encodeCursor = (snap) => Buffer.from(JSON.stringify(snap)).toString('base64url');
60
+ export function decodeCursor(c) {
61
+ try { return JSON.parse(Buffer.from(String(c), 'base64url').toString('utf8')); } catch { return null; }
62
+ }
63
+
64
+ /**
65
+ * Block until a change needs the caller, or the timeout passes.
66
+ * @returns {Promise<{cursor:string, changes:object[], timedOut:boolean, note?:string}>}
67
+ */
68
+ export async function waitForChange({ cwd = process.cwd(), since = null, timeoutS = 300, pollMs = 2000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
69
+ const first = snapshot(cwd);
70
+ const prev = since ? decodeCursor(since) : null;
71
+ if (!prev) {
72
+ return { cursor: encodeCursor(first), changes: [], timedOut: false, note: since ? 'unreadable cursor — here is a fresh one' : 'first call — pass this cursor back to wait' };
73
+ }
74
+ const deadline = Date.now() + Math.min(MAX_TIMEOUT_S, Math.max(1, timeoutS)) * 1000;
75
+ let snap = first;
76
+ for (;;) {
77
+ const changes = diffSnapshots(prev, snap);
78
+ if (changes.length) return { cursor: encodeCursor(snap), changes, timedOut: false };
79
+ if (Date.now() >= deadline) return { cursor: encodeCursor(snap), changes: [], timedOut: true };
80
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
81
+ snap = snapshot(cwd);
82
+ }
83
+ }
84
+
85
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
86
+ if (isMain) {
87
+ const a = process.argv.slice(2);
88
+ const opt = (k, d) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : d; };
89
+ const r = await waitForChange({ cwd: opt('--cwd', process.cwd()), since: opt('--since', null), timeoutS: Number(opt('--timeout', 300)) });
90
+ process.stdout.write(`${JSON.stringify(r)}\n`);
91
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * session-status — which sessions in this project are waiting for a person.
3
+ *
4
+ * Nothing showed it. On the projects measured on 2026-09-21 the operator was the
5
+ * approval loop (1,181 "делай" in 5,126 messages), and the board could not say
6
+ * which session was sitting on a permission prompt or had finished its turn and
7
+ * was waiting for an answer. Idea from fynnfluegge/agtx (Apache-2.0), which drives
8
+ * its board from the same hook events; the code here is written for this project.
9
+ *
10
+ * Three hook events write one small file per session, `.great_cto/status/<id>.json`:
11
+ *
12
+ * Notification → blocked (a permission prompt, or idle waiting for input;
13
+ * the notification's own message is the reason)
14
+ * Stop → waiting (the turn ended; the next move is the operator's)
15
+ * UserPromptSubmit → working
16
+ * SessionEnd → the file is removed
17
+ *
18
+ * A hook only fires on the transitions it is told about, so a state can go stale:
19
+ * a permission granted fires no event. The reader therefore checks the session's
20
+ * transcript — if it was written after the blocked/waiting moment, the session has
21
+ * moved on and is reported as working. A `working` with a silent transcript for
22
+ * five minutes is `unknown`, not working (agtx uses the same bound).
23
+ */
24
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync, renameSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ export const STALE_WORKING_MS = 5 * 60 * 1000;
28
+ export const MOVED_ON_SLACK_MS = 2000;
29
+ export const DROP_AFTER_MS = 24 * 60 * 60 * 1000;
30
+
31
+ const EVENT_STATE = { Notification: 'blocked', Stop: 'waiting', UserPromptSubmit: 'working' };
32
+
33
+ const safeId = (id) => String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
34
+
35
+ /** Apply one hook payload. Returns what was written, or null when nothing applies. */
36
+ export function recordHookEvent(payload, { now = Date.now() } = {}) {
37
+ const cwd = payload?.cwd || process.cwd();
38
+ const id = safeId(payload?.session_id);
39
+ if (!id) return null;
40
+ const dir = join(cwd, '.great_cto', 'status');
41
+ const file = join(dir, `${id}.json`);
42
+ const event = payload?.hook_event_name;
43
+ if (event === 'SessionEnd') { rmSync(file, { force: true }); return { removed: true }; }
44
+ const state = EVENT_STATE[event];
45
+ if (!state) return null;
46
+ try { statSync(join(cwd, '.great_cto')); } catch { return null; } // not a great_cto project
47
+ const rec = {
48
+ session: id,
49
+ state,
50
+ since: new Date(now).toISOString(),
51
+ transcript: payload.transcript_path || null,
52
+ };
53
+ if (state === 'blocked') {
54
+ rec.reason = String(payload.message || 'waiting for you').slice(0, 300);
55
+ if (payload.notification_type) rec.kind = String(payload.notification_type).slice(0, 40);
56
+ }
57
+ mkdirSync(dir, { recursive: true });
58
+ const tmp = `${file}.${process.pid}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify(rec));
60
+ renameSync(tmp, file);
61
+ return rec;
62
+ }
63
+
64
+ /**
65
+ * The effective state of every session with a status file.
66
+ * @returns {{session:string, state:'blocked'|'waiting'|'working'|'unknown', reason?:string, kind?:string, since:string}[]}
67
+ */
68
+ export function readSessionStatus(projectDir, { now = Date.now() } = {}) {
69
+ const dir = join(projectDir, '.great_cto', 'status');
70
+ let files = [];
71
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return []; }
72
+ const out = [];
73
+ for (const f of files) {
74
+ let rec;
75
+ try { rec = JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { continue; }
76
+ const since = Date.parse(rec.since);
77
+ if (!Number.isFinite(since) || now - since > DROP_AFTER_MS) continue;
78
+ let moved = null;
79
+ if (rec.transcript) {
80
+ try { moved = statSync(rec.transcript).mtimeMs; } catch { moved = null; }
81
+ }
82
+ let state = rec.state;
83
+ if ((state === 'blocked' || state === 'waiting') && moved !== null && moved > since + MOVED_ON_SLACK_MS) state = 'working';
84
+ if (state === 'working' && now - Math.max(since, moved || 0) > STALE_WORKING_MS) state = 'unknown';
85
+ const row = { session: rec.session, state, since: rec.since };
86
+ if (state === 'blocked') { row.reason = rec.reason; if (rec.kind) row.kind = rec.kind; }
87
+ out.push(row);
88
+ }
89
+ const rank = { blocked: 0, waiting: 1, working: 2, unknown: 3 };
90
+ return out.sort((a, b) => rank[a.state] - rank[b.state] || (a.since < b.since ? -1 : 1));
91
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ship-evidence — what the verdict logs say about whether gate:ship may pass.
3
+ *
4
+ * Read across the 16 projects with verdicts on the measuring machine (2026-09-21):
5
+ *
6
+ * - a healthcare-reviewer BLOCKED was never followed by a later verdict of its
7
+ * own, and the task it blocked was closed an hour later;
8
+ * - 11 of the 16 had no qa-engineer verdict at all;
9
+ * - great_cto's own last QA verdict was three weeks older than its last ship.
10
+ *
11
+ * gate-check read Beads task states and required domain reviewers; it read no
12
+ * verdict's VALUE. This does, three rules:
13
+ *
14
+ * 1. An agent whose LATEST verdict is negative (BLOCKED, FAIL, REJECTED, REWORK)
15
+ * has an open finding. A later positive verdict from the same agent closes it.
16
+ * 2. qa-engineer and security-officer must each have a positive latest verdict.
17
+ * 3. Both must be newer than the last commit that changed code — a verdict from
18
+ * before the change it is supposed to cover is not evidence about it.
19
+ *
20
+ * Agent names are read from the log's file name and normalised: the same agent
21
+ * was found logged as `code-reviewer`, `great-cto:code-reviewer`,
22
+ * `great_cto:code-reviewer`, and QA as both `qa` and `qa-engineer`.
23
+ */
24
+ import { readdirSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { spawnSync } from 'node:child_process';
27
+ import { parseVerdictLog } from './verdict-record.mjs';
28
+
29
+ export const MANDATORY_FOR_SHIP = ['qa-engineer', 'security-officer'];
30
+
31
+ const ALIASES = { qa: 'qa-engineer', security: 'security-officer', sec: 'security-officer' };
32
+
33
+ /** `great-cto:qa`, `great_cto:qa-engineer`, `qa` → `qa-engineer`. */
34
+ export function canonicalAgent(name) {
35
+ const bare = String(name || '').trim().toLowerCase().replace(/^.*:/, '').replace(/\.log$/, '');
36
+ return ALIASES[bare] || bare;
37
+ }
38
+
39
+ /** negative | positive | neutral, from a verdict value in any dialect written so far. */
40
+ export function verdictKind(v) {
41
+ const s = String(v || '').toUpperCase();
42
+ if (/^(BLOCKED|FAIL|FAILED|REJECTED|REWORK)\b/.test(s)) return 'negative';
43
+ // `APPROVED_WITH_DEFERRED_CRITICALS` and friends are approvals that name open
44
+ // criticals; they are not a clean pass and are read as negative.
45
+ if (/^APPROVED.*(DEFERRED|CRITICAL)/.test(s)) return 'negative';
46
+ if (/^(APPROVED|PASS|PASSED|DONE|OK)\b/.test(s)) return 'positive';
47
+ return 'neutral';
48
+ }
49
+
50
+ const tsOf = (ts) => { const t = Date.parse(ts); return Number.isFinite(t) ? t : null; };
51
+
52
+ /**
53
+ * The latest verdict per agent in `<dir>/.great_cto/verdicts/`.
54
+ * `gate:*.log` files record gate decisions, not an agent's verdict, and are skipped.
55
+ * @returns {Map<string, {verdict:string, ts:string, kind:string}>}
56
+ */
57
+ export function latestVerdicts(projectDir) {
58
+ const dir = join(projectDir, '.great_cto', 'verdicts');
59
+ const out = new Map();
60
+ let files = [];
61
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.log') && !f.startsWith('gate')); } catch { return out; }
62
+ for (const f of files) {
63
+ const agent = canonicalAgent(f);
64
+ let text = '';
65
+ try { text = readFileSync(join(dir, f), 'utf8'); } catch { continue; }
66
+ for (const rec of parseVerdictLog(text, { agent }).records) {
67
+ const t = tsOf(rec.ts);
68
+ if (t === null) continue;
69
+ const prev = out.get(agent);
70
+ if (!prev || t >= tsOf(prev.ts)) out.set(agent, { verdict: rec.verdict, ts: rec.ts, kind: verdictKind(rec.verdict) });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** ISO time of the last commit that changed something other than docs and pipeline records. */
77
+ export function lastCodeChange(projectDir) {
78
+ const r = spawnSync('git', ['log', '-1', '--format=%cI', '--', '.',
79
+ ':(exclude).great_cto', ':(exclude)docs', ':(exclude)*.md', ':(exclude).beads'],
80
+ { cwd: projectDir, encoding: 'utf8', timeout: 10_000 });
81
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
82
+ }
83
+
84
+ /**
85
+ * @param {Map} latest from latestVerdicts()
86
+ * @param {string|null} lastChange from lastCodeChange()
87
+ * @param {{as?: string}} opts `as`: the agent running this check about its own
88
+ * verdict — its own latest is not held against it.
89
+ * @returns {{agent:string, why:string}[]} what blocks gate:ship, each with its reason
90
+ */
91
+ export function shipBlockers(latest, lastChange, { as } = {}) {
92
+ const self = as ? canonicalAgent(as) : null;
93
+ const out = [];
94
+ for (const [agent, v] of latest) {
95
+ if (agent === self || v.kind !== 'negative') continue;
96
+ out.push({ agent, why: `latest verdict ${v.verdict} (${v.ts}) — no later verdict from ${agent} closes it` });
97
+ }
98
+ for (const agent of MANDATORY_FOR_SHIP) {
99
+ if (agent === self) continue;
100
+ const v = latest.get(agent);
101
+ if (!v) { out.push({ agent, why: 'no verdict in this project' }); continue; }
102
+ if (v.kind === 'neutral') out.push({ agent, why: `latest verdict ${v.verdict} is not a pass` });
103
+ }
104
+ // A verdict counts for the code it saw. Both post-implementation checks must be
105
+ // newer than the last code change; agtx (fynnfluegge/agtx) applies the same rule
106
+ // to a phase artifact — only one written after the phase began counts.
107
+ for (const agent of MANDATORY_FOR_SHIP) {
108
+ if (agent === self) continue;
109
+ const v = latest.get(agent);
110
+ if (v && v.kind === 'positive' && lastChange && tsOf(v.ts) !== null && tsOf(v.ts) < tsOf(lastChange)) {
111
+ out.push({ agent, why: `verdict ${v.ts} predates the last code change ${lastChange} — re-run ${agent} on what ships` });
112
+ }
113
+ }
114
+ return out;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.31.0",
3
+ "version": "3.33.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",