great-cto 3.75.10 → 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/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.
|
|
4
|
+
"version": "3.76.0",
|
|
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
|
|
@@ -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
|
```
|
package/board/agents/devops.md
CHANGED
|
@@ -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
|
-
##
|
|
165
|
+
## Workspace
|
|
166
166
|
|
|
167
|
-
|
|
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
|
-
|
|
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
|
+
}
|