great-cto 3.75.10 → 3.76.1

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 (30) 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/vendor/PROVENANCE.md +5 -3
  18. package/board/packages/board/public/assets/vendor/purify.min.js +7 -3
  19. package/board/packages/board/public/assets/work-view.js +83 -3
  20. package/board/packages/board/public/index.html +112 -1
  21. package/board/packages/board/public/manifest.webmanifest +16 -0
  22. package/board/packages/board/public/sw.js +1 -1
  23. package/board/packages/board/route-coverage-baseline.json +25 -0
  24. package/board/packages/board/server.mjs +8 -1
  25. package/board/scripts/lib/gate-run-record.mjs +75 -0
  26. package/board/scripts/lib/handoff-state.mjs +478 -0
  27. package/board/scripts/lib/workspace.mjs +277 -0
  28. package/board/scripts/lib/worktree-state.mjs +128 -0
  29. package/board/shared/pipeline.toml +29 -0
  30. package/package.json +1 -1
@@ -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 72 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.75.10",
4
+ "version": "3.76.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -666,6 +666,10 @@ manual configuration needed.
666
666
  ```
667
667
  `auto` cost → real token spend via cost-meter, not a guess.
668
668
 
669
+ **The feature has a user-facing surface** (T1 or higher): record `APPROVED_UI`
670
+ instead of `APPROVED`. That token is what sends the chain through
671
+ `design-advisor` before `pm`; with plain `APPROVED` the design stage never starts.
672
+
669
673
  Then create epic + implementation tasks:
670
674
  ```bash
671
675
  bd create "ARCH: <feature>" --type epic --priority 1
@@ -11,6 +11,7 @@ memory: project
11
11
  color: red
12
12
  skills:
13
13
  - done-blocked
14
+ - build-speed
14
15
  ---
15
16
 
16
17
  # ci-resolver
@@ -157,6 +157,8 @@ so every level needs a second signal.
157
157
  - You **never** write production UI code (no `.tsx`/`.css`/`.html` implementation).
158
158
  That is senior-dev's job, working from your DESIGN doc. If you find yourself
159
159
  writing a component body, stop and put the contract in the doc instead.
160
+ - One exception, draft-screens mode below: a throwaway HTML sketch that nothing
161
+ ships from.
160
162
 
161
163
  ## Phase task tracking (mandatory)
162
164
 
@@ -170,6 +172,30 @@ Run before or parallel to `pm`. On `change_tier` **T0 (maintenance) you do not
170
172
  run** — design is a T1+ concern (see `scripts/lib/change-tier.mjs` /
171
173
  `effectiveGates`). Skip yourself for a pure fix.
172
174
 
175
+ The architect sends you here with `APPROVED_UI`. `product-owner` can also send you
176
+ here earlier with `SCREENS_NEEDED`, before any architecture exists. That is
177
+ draft-screens mode.
178
+
179
+ ## Draft-screens mode (`SCREENS_NEEDED`)
180
+
181
+ The CTO is about to decide whether to build this, and wants to see it. You have the
182
+ brief and its `## Key scenarios`; no ARCH doc exists and none is needed.
183
+
184
+ 1. Draw **3 to 5 screens of the main scenario** into one self-contained file,
185
+ `docs/design/SCREENS-<slug>.html`: inline CSS, no external requests, links between
186
+ screens so the scenario can be clicked through. Screens another scenario needs
187
+ wait for the full design.
188
+ 2. Use realistic content from the brief, never lorem ipsum. Show one failure or
189
+ empty state if the scenario depends on it.
190
+ 3. Put a visible banner on every screen: `Draft: for discussion, not a design
191
+ decision`. Nothing here commits the design system, the tokens or the stack.
192
+ 4. Add a `## Draft screens` line with the path to the brief, then record
193
+ `SCREENS_DRAFT`. The dispatcher raises `gate:product` from that verdict.
194
+
195
+ Stop there: none of the sections below, no DESIGN doc. After `architect` you run
196
+ again in full. The drafts are then an input you may overrule, and §10 says where you
197
+ did and why.
198
+
173
199
  ## Inputs (read first)
174
200
 
175
201
  1. `.great_cto/PROJECT.md` — archetype, project_size, brand, compliance, platform.
@@ -336,4 +362,6 @@ parse it; `auto` records real token cost:
336
362
 
337
363
  ```bash
338
364
  bash scripts/log-verdict.sh design-advisor <DONE|BLOCKED> auto design=docs/design/DESIGN-<slug>.md
365
+ # draft-screens mode:
366
+ bash scripts/log-verdict.sh design-advisor SCREENS_DRAFT auto feature=<slug> screens=docs/design/SCREENS-<slug>.html
339
367
  ```
@@ -17,6 +17,7 @@ skills:
17
17
  - observability-baseline
18
18
  - deploy-landed
19
19
  - signing-preflight
20
+ - build-speed
20
21
  ---
21
22
 
22
23
  You are the DevOps Engineer. Deploy after security approval.
@@ -57,6 +57,8 @@ fine. Each is the thing you are here to test, not a premise to build on:
57
57
  │
58
58
  👤 product-owner ← YOU. frame → brainstorm → debate → synthesize
59
59
  ▼
60
+ design-advisor (draft screens) ← UI products only, when you record SCREENS_NEEDED
61
+ ▼
60
62
  👤 gate:product ← the CTO approves the brief (the one human gate: WHAT before HOW)
61
63
  │
62
64
  architect → pm → senior-dev → reviewers → qa → devops (HOW, automated)
@@ -352,6 +354,15 @@ Then raise **gate:product**.
352
354
  > archetype is high-compliance, are `team-size`, `cost-cap-usd-month`, `geo` all set?
353
355
  > If any is missing, the CTO's approval is wasted because architect will bounce it.
354
356
 
357
+ **Show it before it is approved.** When the call is BUILD, the product has a
358
+ user-facing surface and `gate:product` is active at this approval level, add a
359
+ `## Key scenarios` section to the brief: who does what, step by step, for the main
360
+ scenario and at most two others. Then record `SCREENS_NEEDED` and do **not** raise
361
+ the gate: `design-advisor` draws the drafts and the gate rises when they exist.
362
+ Skip this for a fix, a backend or CLI product, or a level where the gate is off: a
363
+ draft nobody is asked about is money spent on nothing. You write the scenarios, not
364
+ the screens; drawing them is not your job.
365
+
355
366
  If the call is DON'T BUILD: write `.great_cto/DISCOVERY-NO-BUILD.md` (problem,
356
367
  why-no-build, what would change the decision) and stop — do not hand to architect.
357
368
 
@@ -365,6 +376,7 @@ PRODUCT BRIEF: <title>
365
376
  The bet: <one line>
366
377
  Panel: <FOR n · AGAINST n · decisive point>
367
378
  Scope v1: <bullets>
379
+ Screens: <docs/design/SCREENS-<slug>.html, or none>
368
380
  → approve · comment (I revise, max 3 rounds) · reject
369
381
  ```
370
382
 
@@ -396,5 +408,5 @@ Before your final report, record the canonical verdict line (see
396
408
  parse it; `auto` records real token cost:
397
409
 
398
410
  ```bash
399
- bash scripts/log-verdict.sh product-owner <APPROVED|NO_BUILD> auto feature=<slug> brief=docs/product/BRIEF-<slug>.md
411
+ bash scripts/log-verdict.sh product-owner <APPROVED|SCREENS_NEEDED|NO_BUILD> auto feature=<slug> brief=docs/product/BRIEF-<slug>.md
400
412
  ```
@@ -162,22 +162,38 @@ files or items, dispatch them as parallel subagents in the **same turn** (e.g. g
162
162
  smoke tests for several modules at once). Do **not** spawn a subagent for work you can finish
163
163
  directly in this response — refactoring a function you can already see, editing one file.
164
164
 
165
- ## Worktree isolation
165
+ ## Workspace
166
166
 
167
- `isolation: worktree` is set — the orchestrator will try to create a git worktree for each parallel task.
167
+ **Before your first edit**, ask for this task's workspace:
168
+
169
+ ```bash
170
+ WS="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,"/"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | sed 's|/$||')}/scripts/workspace.mjs"
171
+ [ -f "$WS" ] || WS="$(pwd)/scripts/workspace.mjs"
172
+ node "$WS" claim <task-id>
173
+ ```
174
+
175
+ It prints one JSON object. Act on it exactly:
176
+
177
+ | Answer | What you do |
178
+ |---|---|
179
+ | `state` is `adopt`, `reuse` or `create`, and `ok` is true | `cd` to `path` and do all the work there, on `branch`. A server you start uses `port`. |
180
+ | `ok` is false | The workspace exists but `dependencies` or `setup` failed. Stop and report BLOCKED with that field. |
181
+ | `state` is `conflict` | Stop and report BLOCKED with `reason`. Do not delete, re-point or reuse that directory: it is unexplained work. |
182
+ | `state` is `unavailable` | Stop and report BLOCKED with `reason`. Do **not** continue in the shared tree. |
183
+ | `mode` is `shared` | The project has not opted in. Follow the section below. |
184
+
185
+ After your branch is merged, run `node "$WS" archive <task-id>`. A refusal names its reason and is not an error: leave the worktree alone. Never run `git worktree remove` yourself.
186
+
187
+ ### When `mode` is `shared`
188
+
189
+ `isolation: worktree` is set, so the orchestrator will try to create a git worktree for each parallel task.
168
190
 
169
191
  **If you receive a worktree error** ("not in a git repository", "WorktreeCreate hooks not configured", "no commits"):
170
192
  1. Do NOT abort. Continue in the main working directory without isolation.
171
193
  2. Prefix your first output with: `⚠ Running without worktree isolation (hooks not configured) — changes go to main working tree.`
172
194
  3. Extra caution: stage and commit frequently (`git add -p && git commit`) to create checkpoints, since there is no branch isolation.
173
195
 
174
- Worktree hooks setup (for the CTO to fix later): add to `~/.claude/settings.json`:
175
- ```json
176
- "hooks": {
177
- "WorktreeCreate": [{"hooks": [{"type": "command", "command": "mkdir -p $WORKTREE_PATH && git worktree add $WORKTREE_PATH -b $WORKTREE_BRANCH 2>/dev/null || true"}]}],
178
- "WorktreeRemove": [{"hooks": [{"type": "command", "command": "git worktree remove $WORKTREE_PATH --force 2>/dev/null || true"}]}]
179
- }
180
- ```
196
+ The hook setup that fixes this is printed by `/start` in its pre-flight.
181
197
 
182
198
  ## Session Memory
183
199
 
@@ -34,6 +34,8 @@ import { isFailure } from './fleet.mjs';
34
34
  import { getShareState, toggleShare } from './share.mjs';
35
35
  import { checkForRelease, buildUpdatePayload } from './update-alert.mjs';
36
36
  import { log } from './log.mjs';
37
+ import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
38
+ import { blockedAgentEvents, redGateRunEvents, waitingGateEvents, readGateRuns, selectedInstantTriggers } from './instant-alerts.mjs';
37
39
 
38
40
  // ── Email alerts (Resend) ─────────────────────────────────────────────────
39
41
  // Dispatch model: read webhooks.json on every fire (idempotent if disabled
@@ -161,10 +163,48 @@ async function firePushAlert(eventName, dedupeKey, payload) {
161
163
  } catch (e) { log.warn('firePushAlert failed:', e.message); }
162
164
  }
163
165
 
166
+ /**
167
+ * One pass of the instant triggers over every known project. Exported so a test
168
+ * can drive it without waiting for the timer.
169
+ * @returns {Promise<{event:string,dedupeKey:string}[]>} what was sent this pass
170
+ */
171
+ async function sweepInstantAlerts({ now = Date.now(), projects = listProjects(), greatCtoDir = GREAT_CTO_DIR,
172
+ send = { email: fireEmailAlert, push: firePushAlert, history: addNotification } } = {}) {
173
+ const on = selectedInstantTriggers(greatCtoDir);
174
+ if (!on.size) return [];
175
+ const sent = [];
176
+ for (const proj of projects) {
177
+ const events = [];
178
+ if (on.has('agent.blocked')) events.push(...blockedAgentEvents(proj.slug, readSessionStatus(proj.path, { now }), { now }));
179
+ if (on.has('gate.local_red')) events.push(...redGateRunEvents(proj.slug, readGateRuns(proj.path), { now }));
180
+ if (on.has('gate.waiting')) events.push(...waitingGateEvents(proj.slug, getTasks(proj.path, SWEEP), { now }));
181
+ for (const ev of events) {
182
+ // Email and push each mark what THEY sent, and only when configured. The
183
+ // sweep marks the instance itself, before sending, so with neither channel
184
+ // set up it still reaches the in-app list once, not once a minute.
185
+ // An unreadable map is not an empty one: writing `{}` plus one key back
186
+ // would erase every other alert's dedupe and re-send them all. Stop instead.
187
+ const fired = fs.existsSync(ALERTS_FIRED_PATH) ? readAlertsHistory() : {};
188
+ if (!fired) { log.warn('instant alerts: alerts-fired.json is unreadable — nothing sent this pass'); return sent; }
189
+ const mark = `instant:${ev.dedupeKey}`;
190
+ if (fired[mark]) continue;
191
+ fired[mark] = new Date(now).toISOString();
192
+ const keys = Object.keys(fired);
193
+ writeAlertsFired(keys.length > 1000 ? Object.fromEntries(keys.slice(-1000).map((k) => [k, fired[k]])) : fired);
194
+ send.history(ev.event, ev.payload, ev.dedupeKey);
195
+ await send.email(ev.event, ev.dedupeKey, ev.payload);
196
+ await send.push(ev.event, ev.dedupeKey, ev.payload);
197
+ sent.push({ event: ev.event, dedupeKey: ev.dedupeKey });
198
+ }
199
+ }
200
+ return sent;
201
+ }
202
+
164
203
  // ── Cron: scan gates / cost / weekly digest ───────────────────────────────
165
204
  // Runs every 5 minutes once the server boots. Each check is idempotent
166
205
  // thanks to alerts-fired.json dedupe.
167
206
  function startAlertCron() {
207
+ const ONE_MIN = 60 * 1000;
168
208
  const FIVE_MIN = 5 * 60 * 1000;
169
209
  const ONE_HOUR = 60 * 60 * 1000;
170
210
  const ONE_DAY = 24 * 60 * 60 * 1000;
@@ -302,6 +342,11 @@ function startAlertCron() {
302
342
  } catch (e) { log.warn('cron gate.stale failed:', e.message); }
303
343
  }, FIVE_MIN);
304
344
 
345
+ // agent.blocked / gate.local_red / gate.waiting — the instant triggers.
346
+ // Every minute, and only for triggers the operator switched on (see
347
+ // lib/instant-alerts.mjs for why these three are opt-in on every channel).
348
+ setInterval(() => { sweepInstantAlerts().catch((e) => log.warn('cron instant alerts failed:', e.message)); }, ONE_MIN);
349
+
305
350
  // cost.threshold: monthly LLM spend at 80% / 100% of budget
306
351
  setInterval(async () => {
307
352
  try {
@@ -519,4 +564,4 @@ function startAlertCron() {
519
564
  log.info('Alert cron started: gate.stale (5min), sla.escalate (5min), connector.health (5min), cost.threshold (1h), digest.daily (Mon–Fri 08:00), digest.weekly (Fri 09:00), report.daily (09:00), update.available (24h)');
520
565
  }
521
566
 
522
- export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron };
567
+ export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron, sweepInstantAlerts };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # gate-run.sh <worktree> <branch> <main checkout>: one full local gate on a worktree,
3
+ # started from the board's merge-readiness card. It waits for a quiet machine and
4
+ # keeps <main checkout>/.great_cto/gate-queue.json current so the card can say so.
5
+ # Its result reaches the card through gate-runs.jsonl, which ci-local writes itself.
6
+ set -u
7
+ W=$1; B=$2; R=$3; Q="$R/.great_cto/gate-queue.json"
8
+ HEAD_SHA="$(git -C "$W" rev-parse HEAD)"
9
+ write() { # state idle ahead
10
+ local tmp="$Q.$$"
11
+ printf '{"branch":"%s","head":"%s","state":"%s","idle":%s,"ahead":%s,"pid":%s}\n' "$B" "$HEAD_SHA" "$1" "${2:-null}" "${3:-0}" "$$" > "$tmp" && mv "$tmp" "$Q"
12
+ }
13
+ LINKED=""
14
+ cleanup() { rm -f "${Q:?}"; for d in $LINKED; do [ -L "$W/$d" ] && rm -f "${W:?}/${d:?}"; done; }
15
+ trap cleanup EXIT
16
+ idle() { top -l 4 -n 0 -s 5 2>/dev/null | grep "CPU usage" | tail -3 | awk '{gsub("%","",$7); s+=$7} END {if (NR) printf "%d", s/NR}'; }
17
+ write queued
18
+ for _ in $(seq 1 480); do
19
+ others=$(ps -axo command | grep -cE "^(/bin/)?bash .*ci-local\.sh" || true); id=$(idle); free=$(df -g / 2>/dev/null | awk 'NR==2{print $4}')
20
+ write queued "${id:-null}" "$others"
21
+ # No reading on this platform is no reason to wait: only a measured busy machine waits.
22
+ [ "$others" = 0 ] && { [ -z "$id" ] || [ "$id" -ge 30 ]; } && { [ -z "$free" ] || [ "$free" -ge 8 ]; } && break
23
+ sleep 45
24
+ done
25
+ cd "$W" || exit 1
26
+ for d in node_modules packages/cli/node_modules; do [ -e "$d" ] || { [ -e "$R/$d" ] && ln -s "$R/$d" "$d" && LINKED="$LINKED $d"; }; done
27
+ [ -d "$HOME/.great_cto/tools/ms-playwright" ] && export PLAYWRIGHT_BROWSERS_PATH="$HOME/.great_cto/tools/ms-playwright"
28
+ (cd packages/cli && npm run build >/dev/null 2>&1) || exit 1
29
+ write running
30
+ bash scripts/ci-local.sh --e2e --jobs=8 > "$R/.great_cto/gate-run.log" 2>&1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The three alerts that speak at once (owner-approved design v16):
3
+ *
4
+ * agent.blocked — a session is sitting on a permission prompt or waiting for
5
+ * input (state written by the Notification hook through
6
+ * scripts/lib/session-status.mjs);
7
+ * gate.local_red — a ci-local run finished with failures (one line per run in
8
+ * <project>/.great_cto/gate-runs.jsonl, written by
9
+ * scripts/lib/gate-run-record.mjs);
10
+ * gate.waiting — a gate opened and is waiting for the operator, reported
11
+ * when it opens instead of after gate.stale's 2 hours.
12
+ *
13
+ * These functions only DECIDE what to send; alerts.mjs sends it. Each event
14
+ * carries a dedupe key that names one instance, so a 1-minute sweep reports an
15
+ * instance once.
16
+ *
17
+ * Fresh-only on purpose: a board that starts, or a trigger that is switched on,
18
+ * must not replay a backlog. Older gates are gate.stale's job; older runs and
19
+ * older blocked sessions are history.
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ /** gate.waiting covers the gap before gate.stale's 2-hour floor. */
25
+ export const GATE_WAITING_WINDOW_MS = 2 * 3600_000;
26
+ /** A red run older than this is history, not news. */
27
+ export const GATE_RUN_WINDOW_MS = 24 * 3600_000;
28
+ /** A blocked state older than this is the session-status DROP horizon anyway. */
29
+ export const BLOCKED_WINDOW_MS = 24 * 3600_000;
30
+
31
+ export const INSTANT_TRIGGERS = Object.freeze(['agent.blocked', 'gate.local_red', 'gate.waiting']);
32
+
33
+ const boardLink = (slug, task) =>
34
+ `http://localhost:3141/?project=${encodeURIComponent(slug)}${task ? `&task=${encodeURIComponent(task)}` : ''}#inbox`;
35
+
36
+ /**
37
+ * @param {string} slug
38
+ * @param {{session:string,state:string,reason?:string,since:string}[]} sessions readSessionStatus() rows
39
+ */
40
+ export function blockedAgentEvents(slug, sessions, { now = Date.now() } = {}) {
41
+ if (!Array.isArray(sessions)) return [];
42
+ const out = [];
43
+ for (const s of sessions) {
44
+ if (!s || s.state !== 'blocked' || !s.session) continue;
45
+ // idle_prompt is Claude Code's reminder that a finished turn is still
46
+ // unanswered — it follows every turn. That is a session done, not an agent
47
+ // stuck; alerting on it would page the operator after every reply.
48
+ if (s.kind === 'idle_prompt') continue;
49
+ const since = Date.parse(s.since);
50
+ if (!Number.isFinite(since) || now - since > BLOCKED_WINDOW_MS) continue;
51
+ const reason = String(s.reason || 'waiting for your input').slice(0, 160);
52
+ out.push({
53
+ event: 'agent.blocked',
54
+ dedupeKey: `agent.blocked:${slug}:${s.session}:${s.since}`,
55
+ payload: {
56
+ title: `An agent is blocked — ${slug}`,
57
+ body: `${slug} · ${reason}\n\nOpens the board. Nothing is approved from a notification.`,
58
+ level: 'warning',
59
+ project: slug,
60
+ link: boardLink(slug),
61
+ action: 'Open in board',
62
+ kv: { session: String(s.session).slice(0, 12), since: s.since, reason },
63
+ },
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * @param {string} slug
71
+ * @param {object[]|null} runs readGateRuns() records
72
+ */
73
+ export function redGateRunEvents(slug, runs, { now = Date.now() } = {}) {
74
+ if (!Array.isArray(runs)) return [];
75
+ const out = [];
76
+ for (const r of runs) {
77
+ if (!r || r.result !== 'red' || !r.ts) continue;
78
+ const ts = Date.parse(r.ts);
79
+ if (!Number.isFinite(ts) || now - ts > GATE_RUN_WINDOW_MS) continue;
80
+ const failed = Array.isArray(r.failed) ? r.failed.map(String).slice(0, 8) : [];
81
+ const where = [r.branch, r.sha].filter(Boolean).join(' @ ') || 'unknown commit';
82
+ out.push({
83
+ event: 'gate.local_red',
84
+ dedupeKey: `gate.local_red:${slug}:${r.ts}:${r.sha || ''}`,
85
+ payload: {
86
+ title: `Local gate red — ${slug} · ${where}`,
87
+ body: `A ci-local run finished with failures.\n\n${failed.length ? `Failed steps:\n- ${failed.join('\n- ')}` : 'Failed steps were not recorded.'}`,
88
+ level: 'error',
89
+ project: slug,
90
+ link: boardLink(slug),
91
+ action: 'Open in board',
92
+ kv: { branch: r.branch || 'unknown', sha: r.sha || 'unknown', failed: failed.length ? failed.join(', ') : 'not recorded', ts: r.ts },
93
+ },
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * @param {string} slug
101
+ * @param {object[]|null} tasks getTasks() rows
102
+ */
103
+ export function waitingGateEvents(slug, tasks, { now = Date.now() } = {}) {
104
+ if (!Array.isArray(tasks)) return [];
105
+ const out = [];
106
+ for (const t of tasks) {
107
+ if (!t || !t.is_gate || !t.id) continue;
108
+ const status = String(t.raw_status || t.status || '').toLowerCase();
109
+ if (status === 'closed' || status === 'done') continue;
110
+ const created = Date.parse(t.created_at || t.updated_at || 0);
111
+ if (!Number.isFinite(created) || now - created > GATE_WAITING_WINDOW_MS) continue;
112
+ const title = String(t.title || t.id).slice(0, 80);
113
+ out.push({
114
+ event: 'gate.waiting',
115
+ dedupeKey: `gate.waiting:${slug}:${t.id}`,
116
+ payload: {
117
+ title: `Gate waiting — ${title} (${slug})`,
118
+ body: `A gate opened and is waiting for your decision. Nothing downstream of it moves until you decide.\n\nGate: ${t.id}\nProject: ${slug}`,
119
+ level: 'warning',
120
+ project: slug,
121
+ link: boardLink(slug, t.id),
122
+ action: 'Review in board',
123
+ kv: { gate: t.id, opened: new Date(created).toISOString().slice(0, 16) },
124
+ },
125
+ });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Which instant triggers the operator switched on, from ~/.great_cto/notifications.json.
132
+ * These triggers are opt-in on EVERY channel (email, push, the in-app list):
133
+ * unlike the five older ones they can fire many times a day, and a channel
134
+ * that pages for what nobody asked for gets switched off.
135
+ */
136
+ export function selectedInstantTriggers(greatCtoDir) {
137
+ try {
138
+ const s = JSON.parse(fs.readFileSync(path.join(greatCtoDir, 'notifications.json'), 'utf8'));
139
+ return new Set((s.triggers || []).filter((t) => INSTANT_TRIGGERS.includes(t)));
140
+ } catch { return new Set(); } // display-only: no settings file = nothing switched on; nothing is written from this
141
+ }
142
+
143
+ /**
144
+ * The project's ci-local run log. `null` when it exists and cannot be read —
145
+ * a failed read is never an empty history. A missing file is [] (no runs yet).
146
+ * Malformed lines are skipped: one bad line must not hide the rest.
147
+ */
148
+ export function readGateRuns(projectDir) {
149
+ const file = path.join(projectDir, '.great_cto', 'gate-runs.jsonl');
150
+ let raw;
151
+ try { raw = fs.readFileSync(file, 'utf8'); }
152
+ catch (e) { return e.code === 'ENOENT' ? [] : null; }
153
+ const out = [];
154
+ for (const line of raw.split('\n')) {
155
+ if (!line.trim()) continue;
156
+ try { const r = JSON.parse(line); if (r && typeof r === 'object') out.push(r); } catch { /* skip */ }
157
+ }
158
+ return out;
159
+ }