great-cto 3.18.0 → 3.20.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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.18.0",
5
+ "version": "3.20.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -9,6 +9,7 @@ import {
9
9
  import { GREAT_CTO_DIR, PUSH_SUBS_FILE, VAPID_KEYS_FILE, VAPID_SUBJECT, BUILD_VERSION } from './config.mjs';
10
10
  import { _reportRepublishDedupeSet } from './state.mjs';
11
11
  import { recurrence } from './alert-recurrence.mjs';
12
+ import { waitingOnYou, dedupeKeyFor } from '../../../scripts/lib/waiting-on-you.mjs';
12
13
  import { listProjects, readProjectMd } from './projects.mjs';
13
14
  import { addNotification } from './notifications.mjs';
14
15
  import { getMetrics } from './metrics.mjs';
@@ -251,26 +252,42 @@ function startAlertCron() {
251
252
  const projects = listProjects();
252
253
  for (const proj of projects) {
253
254
  const tasks = getTasks(proj.path, SWEEP);
254
- const gates = tasks.filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked');
255
- for (const g of gates) {
256
- const created = new Date(g.created_at || g.updated_at || 0).getTime();
257
- const ageHr = (Date.now() - created) / 3600_000;
258
- if (ageHr < 2 || ageHr > 24 * 7) continue;
259
- const dedupeKey = `gate.stale:${proj.slug}:${g.id}`;
255
+ // Which gates are waiting, and for how long the same reader the console
256
+ // hook uses, so the two surfaces cannot tell you different things about
257
+ // the same gate. See scripts/lib/waiting-on-you.mjs.
258
+ //
259
+ // Two silencers were removed here, and each is worth naming because each
260
+ // was reasonable alone and together they produced total silence:
261
+ //
262
+ // · `raw_status !== 'blocked'` — but gate-expiry MARKS a gate blocked at
263
+ // 72h, so this hid precisely the gates that had waited longest.
264
+ // · `ageHr > 24 * 7 → skip`, on the grounds that a gate open past a week
265
+ // is "abandoned, not stale". A decision nobody has made does not get
266
+ // less urgent by ageing; every stage behind it is still stopped.
267
+ //
268
+ // Measured before this change: six gate.stale alerts in the tool's
269
+ // lifetime, most recent 41 days old, over a period that contained a gate
270
+ // sitting open for 29 days.
271
+ const waiting = waitingOnYou(tasks, { limit: Infinity });
272
+ for (const g of waiting.items) {
273
+ const ageHr = g.ageHours;
274
+ // The period lives in the key, so the same dedupe machinery that
275
+ // silenced this forever now repeats it daily, then weekly.
276
+ const dedupeKey = dedupeKeyFor(proj.slug, g);
260
277
  // How often this has happened here. A threshold says the gate is old;
261
278
  // only the history says whether old gates are this project's normal
262
279
  // state. Both readings come from the same file — one as a dedupe set,
263
280
  // this one as history.
264
281
  const seen = recurrence(readAlertsHistory(), { event: 'gate.stale', project: proj.slug });
265
282
  const stalePayload = {
266
- title: `${proj.slug} — ${g.title.slice(0, 60)} pending ${ageHr.toFixed(1)}h`,
267
- body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\n\n${seen.sentence}\n\nGate: ${g.id}\nProject: ${proj.slug}`,
283
+ title: `${proj.slug} — ${g.title.slice(0, 60)} · ${g.why}`,
284
+ body: `${g.why}. Nothing downstream of it can move.\n\n${seen.sentence}\n\nGate: ${g.id}\nProject: ${proj.slug}`,
268
285
  level: 'warning',
269
286
  project: proj.slug,
270
287
  link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(g.id)}#inbox`,
271
288
  action: 'Approve in board',
272
289
  kv: {
273
- gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h`,
290
+ gate: g.id, age: `${ageHr}h`, cadence: g.cadence,
274
291
  // `unknown` is carried through rather than rendered as 0 — a count
275
292
  // nobody took is not a count of none.
276
293
  seen_before: seen.count === null ? 'unknown' : `${seen.atLeast ? '\u2265' : ''}${seen.count}`,
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What is waiting on a human, and for how long. One reader, both surfaces.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Three mechanisms independently decided that old work should stop being
7
+ * mentioned, and each is defensible alone:
8
+ *
9
+ * · `gate.stale` alerts between 2h and 7 days, skips anything marked
10
+ * `blocked`, and dedupes so one gate yields exactly one alert, ever.
11
+ * · `gate-expiry` marks a gate `blocked` at 72h — which silences the above.
12
+ * · `session-pipeline-resume` treats anything past 24h as history rather than
13
+ * work waiting: "a stage that succeeded last week is not work waiting for
14
+ * you, it is something that happened."
15
+ *
16
+ * Together they produce silence. Measured on the author's machine: `gate.stale`
17
+ * had fired six times in its life, most recently 41 days earlier, across a
18
+ * period containing a gate that sat open for 29 days.
19
+ *
20
+ * The rule is inverted here: **age is the reason to speak, not to stop.** A
21
+ * decision nobody has made does not become less urgent by ageing; it becomes
22
+ * the only thing standing between the project and every stage after it.
23
+ *
24
+ * Noise is controlled by RANKING and CADENCE, not by going quiet — see
25
+ * `cadenceFor`. The alternative that was tried is the one being replaced.
26
+ *
27
+ * Both the console hook and the board render this, so the two cannot drift into
28
+ * telling the operator different things about the same gate.
29
+ */
30
+
31
+ /** Below this, a gate is simply in flight. Nagging at once trains the reader to ignore the channel. */
32
+ const NUDGE_FLOOR_HOURS = 2;
33
+
34
+ /**
35
+ * How often to repeat a reminder, given how long it has waited.
36
+ * It decays. It never reaches "never".
37
+ */
38
+ export function cadenceFor(ageHours) {
39
+ return ageHours < 24 * 7 ? 'daily' : 'weekly';
40
+ }
41
+
42
+ function describe(ageHours, wasExpired) {
43
+ const d = Math.floor(ageHours / 24);
44
+ const age = d >= 1 ? `${d}d` : `${Math.round(ageHours)}h`;
45
+ return wasExpired
46
+ ? `waiting ${age} — past the 72h expiry, so nothing downstream can move`
47
+ : `waiting ${age} for your decision`;
48
+ }
49
+
50
+ /**
51
+ * @param {Array|null} tasks the project's tasks, or null when they could not be read
52
+ * @param {{now?: number, limit?: number}} [opts]
53
+ * @returns {{state:'waiting'|'clear'|'unknown', items:Array, total:number,
54
+ * hidden:number, line:string}}
55
+ */
56
+ export function waitingOnYou(tasks, { now = Date.now(), limit = 3 } = {}) {
57
+ if (!Array.isArray(tasks)) {
58
+ // Could-not-read must never render as an empty queue. That substitution is
59
+ // the one this project exists to refuse, and it is what an empty array from
60
+ // a failed read would produce.
61
+ return { state: 'unknown', items: [], total: 0, hidden: 0,
62
+ line: 'Could not read this project’s tasks, so what is waiting on you is unknown.' };
63
+ }
64
+
65
+ const open = [];
66
+ for (const t of tasks) {
67
+ if (!t || !t.is_gate) continue;
68
+ const status = String(t.raw_status || t.status || '').toLowerCase();
69
+ // `blocked` is INCLUDED. gate-expiry sets it at 72h, and a gate the machine
70
+ // gave up on is the one most in need of a human — hiding it was the defect.
71
+ if (status === 'closed' || status === 'done') continue;
72
+ const created = Date.parse(t.created_at || t.updated_at || 0);
73
+ if (!Number.isFinite(created)) continue;
74
+ const ageHours = (now - created) / 3600_000;
75
+ if (ageHours < NUDGE_FLOOR_HOURS) continue;
76
+ open.push({
77
+ id: t.id,
78
+ title: String(t.title || '').slice(0, 80),
79
+ ageHours: Math.round(ageHours),
80
+ expired: status === 'blocked',
81
+ cadence: cadenceFor(ageHours),
82
+ why: describe(ageHours, status === 'blocked'),
83
+ });
84
+ }
85
+
86
+ if (!open.length) {
87
+ return { state: 'clear', items: [], total: 0, hidden: 0,
88
+ line: 'Nothing is waiting on you.' };
89
+ }
90
+
91
+ // Oldest first: the longest wait is the strongest signal, and the one whose
92
+ // cost has already been paid the longest.
93
+ open.sort((a, b) => b.ageHours - a.ageHours);
94
+ const items = open.slice(0, limit);
95
+ const hidden = open.length - items.length;
96
+ const n = open.length;
97
+ const oldest = open[0];
98
+ return {
99
+ state: 'waiting',
100
+ items, total: n, hidden,
101
+ line: `${n} decision${n === 1 ? '' : 's'} waiting on you`
102
+ + `, the oldest for ${Math.floor(oldest.ageHours / 24) || '<1'} day`
103
+ + `${Math.floor(oldest.ageHours / 24) === 1 ? '' : 's'}.`,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The dedupe key an alert should use — the thing that turns "once, ever" into a
109
+ * cadence.
110
+ *
111
+ * `fireEmailAlert` refuses to send twice for the same key, which is correct and
112
+ * was the whole problem: the key was `gate.stale:<project>:<gate-id>`, so one
113
+ * gate produced exactly one alert in its lifetime and then silence, however long
114
+ * it waited.
115
+ *
116
+ * Putting the PERIOD in the key reuses that same machinery to repeat on a
117
+ * schedule. Same gate, same day: one alert. Same gate, tomorrow: a new key, so
118
+ * it speaks again. Once the wait passes a week the period widens to a week —
119
+ * quieter, never silent.
120
+ */
121
+ export function dedupeKeyFor(project, item, now = Date.now()) {
122
+ const d = new Date(now);
123
+ let period;
124
+ if (item.cadence === 'weekly') {
125
+ // ISO week: Thursday of the current week identifies the week uniquely, which
126
+ // avoids the year-boundary bug a naive week number has.
127
+ const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
128
+ t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));
129
+ period = `w${t.toISOString().slice(0, 10)}`;
130
+ } else {
131
+ period = d.toISOString().slice(0, 10);
132
+ }
133
+ return `gate.stale:${project}:${item.id}:${period}`;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.18.0",
3
+ "version": "3.20.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",