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.
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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)}
|
|
267
|
-
body:
|
|
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,
|
|
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