great-cto 3.17.0 → 3.19.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/packages/board/lib/alert-recurrence.mjs +73 -0
- package/board/packages/board/lib/alerts.mjs +53 -9
- package/board/packages/board/lib/beads.mjs +16 -1
- package/board/packages/board/lib/data-readers.mjs +8 -0
- package/board/packages/board/lib/docs.mjs +21 -0
- package/board/packages/board/public/index.html +62 -20
- package/board/scripts/lib/doc-links.mjs +103 -0
- package/board/scripts/lib/waiting-on-you.mjs +134 -0
- package/package.json +1 -1
|
@@ -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.19.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Has this alert fired here before?
|
|
3
|
+
*
|
|
4
|
+
* `alerts-fired.json` has recorded every alert this machine sent, keyed
|
|
5
|
+
* `<event>:<project>:<id>` with the time it fired. It was read only as a dedupe
|
|
6
|
+
* set — "have I already sent THIS instance" — so a gate going stale for the
|
|
7
|
+
* first time in a project and the ninth in a month produced the same sentence,
|
|
8
|
+
* and the operator had no way to tell a one-off from a pattern.
|
|
9
|
+
*
|
|
10
|
+
* A threshold cannot make that distinction; only history can, and the history
|
|
11
|
+
* was already on disk.
|
|
12
|
+
*
|
|
13
|
+
* Three states, because the file is lossy and its absence means something:
|
|
14
|
+
*
|
|
15
|
+
* unknown — no history to read. NOT "first": a count nobody took is not a
|
|
16
|
+
* count of zero, and this is the substitution the project exists
|
|
17
|
+
* to refuse.
|
|
18
|
+
* first — history exists and holds nothing for this rule in this project.
|
|
19
|
+
* recurring — it holds N earlier fires inside the window.
|
|
20
|
+
*
|
|
21
|
+
* `atLeast` marks a count taken from a FULL history: the writer keeps only the
|
|
22
|
+
* last 500 keys, so anything older is gone and the number is a floor.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The writer's cap — see writeAlertsFired in alerts.mjs. */
|
|
26
|
+
const HISTORY_CAP = 500;
|
|
27
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Record<string,string>|null} fired parsed alerts-fired.json, or null
|
|
31
|
+
* when there is no such file
|
|
32
|
+
* @param {{event: string, project: string, now?: number, windowDays?: number}} q
|
|
33
|
+
* @returns {{state:'unknown'|'first'|'recurring', count:number|null,
|
|
34
|
+
* windowDays:number, atLeast:boolean, sentence:string}}
|
|
35
|
+
*/
|
|
36
|
+
export function recurrence(fired, { event, project, now = Date.now(), windowDays = DEFAULT_WINDOW_DAYS }) {
|
|
37
|
+
if (!fired || typeof fired !== 'object') {
|
|
38
|
+
return {
|
|
39
|
+
state: 'unknown', count: null, windowDays, atLeast: false,
|
|
40
|
+
sentence: 'No alert history on this machine, so whether this has happened before is unknown.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Anchored on the full `event:project:` prefix. A bare startsWith(event) would
|
|
45
|
+
// count `gate.stalest` as `gate.stale`, and a project slug may itself contain
|
|
46
|
+
// a colon — so both parts are matched as one literal prefix.
|
|
47
|
+
const prefix = `${event}:${project}:`;
|
|
48
|
+
const cutoff = now - windowDays * 86_400_000;
|
|
49
|
+
const keys = Object.keys(fired);
|
|
50
|
+
|
|
51
|
+
let count = 0;
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
if (!k.startsWith(prefix)) continue;
|
|
54
|
+
const at = Date.parse(fired[k]);
|
|
55
|
+
// An unreadable timestamp is not evidence of a recent fire. Skipped, not
|
|
56
|
+
// counted as now — which is what a NaN comparison would have done silently.
|
|
57
|
+
if (!Number.isFinite(at) || at < cutoff) continue;
|
|
58
|
+
count++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const atLeast = keys.length >= HISTORY_CAP;
|
|
62
|
+
if (count === 0) {
|
|
63
|
+
return {
|
|
64
|
+
state: 'first', count: 0, windowDays, atLeast,
|
|
65
|
+
sentence: `First time this has fired for this project in ${windowDays} days.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
state: 'recurring', count, windowDays, atLeast,
|
|
70
|
+
sentence: `${atLeast ? 'At least ' : ''}${count} other time${count === 1 ? '' : 's'} `
|
|
71
|
+
+ `in the last ${windowDays} days for this project — a pattern, not a one-off.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from '../push-adapter.mjs';
|
|
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
|
+
import { recurrence } from './alert-recurrence.mjs';
|
|
12
|
+
import { waitingOnYou, dedupeKeyFor } from '../../../scripts/lib/waiting-on-you.mjs';
|
|
11
13
|
import { listProjects, readProjectMd } from './projects.mjs';
|
|
12
14
|
import { addNotification } from './notifications.mjs';
|
|
13
15
|
import { getMetrics } from './metrics.mjs';
|
|
@@ -43,6 +45,22 @@ function readAlertsFired() {
|
|
|
43
45
|
try { return JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8')); } catch { return {}; }
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The same file, read as HISTORY rather than as a dedupe set.
|
|
50
|
+
*
|
|
51
|
+
* readAlertsFired answers "have I already sent this instance", and `{}` on
|
|
52
|
+
* failure is right for that: not knowing must never block a send. Read as
|
|
53
|
+
* history, that same `{}` says "this has never happened here" — a claim nobody
|
|
54
|
+
* checked. `null` keeps the two apart.
|
|
55
|
+
*/
|
|
56
|
+
function readAlertsHistory() {
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(ALERTS_FIRED_PATH)) return null;
|
|
59
|
+
const parsed = JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8'));
|
|
60
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
function writeAlertsFired(map) {
|
|
47
65
|
try {
|
|
48
66
|
if (!fs.existsSync(GREAT_CTO_DIR)) fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
|
|
@@ -234,20 +252,46 @@ function startAlertCron() {
|
|
|
234
252
|
const projects = listProjects();
|
|
235
253
|
for (const proj of projects) {
|
|
236
254
|
const tasks = getTasks(proj.path, SWEEP);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
|
277
|
+
// How often this has happened here. A threshold says the gate is old;
|
|
278
|
+
// only the history says whether old gates are this project's normal
|
|
279
|
+
// state. Both readings come from the same file — one as a dedupe set,
|
|
280
|
+
// this one as history.
|
|
281
|
+
const seen = recurrence(readAlertsHistory(), { event: 'gate.stale', project: proj.slug });
|
|
243
282
|
const stalePayload = {
|
|
244
|
-
title: `${proj.slug} — ${g.title.slice(0, 60)}
|
|
245
|
-
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}`,
|
|
246
285
|
level: 'warning',
|
|
247
286
|
project: proj.slug,
|
|
248
287
|
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(g.id)}#inbox`,
|
|
249
288
|
action: 'Approve in board',
|
|
250
|
-
kv: {
|
|
289
|
+
kv: {
|
|
290
|
+
gate: g.id, age: `${ageHr}h`, cadence: g.cadence,
|
|
291
|
+
// `unknown` is carried through rather than rendered as 0 — a count
|
|
292
|
+
// nobody took is not a count of none.
|
|
293
|
+
seen_before: seen.count === null ? 'unknown' : `${seen.atLeast ? '\u2265' : ''}${seen.count}`,
|
|
294
|
+
},
|
|
251
295
|
};
|
|
252
296
|
fireEmailAlert('gate.stale', dedupeKey, stalePayload);
|
|
253
297
|
addNotification('gate.stale', stalePayload, dedupeKey);
|
|
@@ -682,7 +682,22 @@ function getReadDegradation(cwd = process.cwd()) {
|
|
|
682
682
|
// tasks.md first: if that file exists and is broken, that is the specific
|
|
683
683
|
// problem. Otherwise report bd's failure, which until now was swallowed — the
|
|
684
684
|
// board answered "no tasks" for a project whose database bd refused to open.
|
|
685
|
-
|
|
685
|
+
const fromTasksMd = readDegradation.get(cwd);
|
|
686
|
+
if (fromTasksMd) return fromTasksMd;
|
|
687
|
+
|
|
688
|
+
// A project that never ran `bd init` has no beads store to fail. bd still
|
|
689
|
+
// reports its absence as an error, and reporting THAT as a degradation put a
|
|
690
|
+
// permanent "counts are incomplete" banner over complete counts on every
|
|
691
|
+
// project that tracks tasks in tasks.md — a supported source, not a fallback
|
|
692
|
+
// of last resort. Absent and broken are different states; this file already
|
|
693
|
+
// draws that line for tasks.md ("Missing is a normal state") and now draws it
|
|
694
|
+
// for beads too.
|
|
695
|
+
//
|
|
696
|
+
// Deliberately narrow: an existing .beads/ that bd cannot open is still a
|
|
697
|
+
// defect and is still reported. Only absence is forgiven.
|
|
698
|
+
if (checkBeadsAvailable(cwd)) return null;
|
|
699
|
+
|
|
700
|
+
return bdFailureFor(cwd) || null;
|
|
686
701
|
}
|
|
687
702
|
|
|
688
703
|
function parseTasksMd(cwd) {
|
|
@@ -27,6 +27,14 @@ function getMemory(cwd = process.cwd()) {
|
|
|
27
27
|
];
|
|
28
28
|
const result = layers.map(l => ({
|
|
29
29
|
...l,
|
|
30
|
+
// `path` opens the file; `displayPath` is what a person — or a README
|
|
31
|
+
// screenshot — sees. An absolute path here names the operator's home
|
|
32
|
+
// directory and username: fine in a local tool, wrong the moment the screen
|
|
33
|
+
// is photographed. The layer already knows its scope, so the display form is
|
|
34
|
+
// derived rather than guessed.
|
|
35
|
+
displayPath: l.scope === 'global'
|
|
36
|
+
? path.join('~', path.relative(home, l.path)).split(path.sep).join('/')
|
|
37
|
+
: path.relative(cwd, l.path).split(path.sep).join('/'),
|
|
30
38
|
content: readFileSafe(l.path),
|
|
31
39
|
exists: fs.existsSync(l.path),
|
|
32
40
|
size: fs.existsSync(l.path) ? fs.statSync(l.path).size : 0,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
|
|
17
|
+
import { linkGraph } from '../../../scripts/lib/doc-links.mjs';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -148,6 +149,10 @@ export function titleFromText(text) {
|
|
|
148
149
|
return m ? m[1].replace(/\.md$/i, '').trim() : null;
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/** Generated summaries and translated copies — see listDocs. */
|
|
153
|
+
const IS_COPY = /\.summary\.md$/i;
|
|
154
|
+
const IS_TRANSLATED = /^docs[/\\][a-z]{2}(-[A-Z]{2})?[/\\]/;
|
|
155
|
+
|
|
151
156
|
function walk(dir, root, out, depth = 0) {
|
|
152
157
|
if (depth > 3 || out.length >= MAX_DOCS) return;
|
|
153
158
|
let entries;
|
|
@@ -158,6 +163,10 @@ function walk(dir, root, out, depth = 0) {
|
|
|
158
163
|
const abs = path.join(dir, e.name);
|
|
159
164
|
if (e.isDirectory()) { walk(abs, root, out, depth + 1); continue; }
|
|
160
165
|
if (!e.name.toLowerCase().endsWith('.md')) continue;
|
|
166
|
+
// A generated summary and a translation are copies of a document, not more
|
|
167
|
+
// documents. Counting them made this screen report 188 where there are 156,
|
|
168
|
+
// and stood a machine-written summary in the index beside its own source.
|
|
169
|
+
if (IS_COPY.test(e.name) || IS_TRANSLATED.test(path.relative(root, abs))) continue;
|
|
161
170
|
let st;
|
|
162
171
|
try { st = fs.statSync(abs); } catch { continue; }
|
|
163
172
|
out.push({ abs, rel: path.relative(root, abs), size: st.size, modified: st.mtime.toISOString() });
|
|
@@ -336,6 +345,17 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
|
336
345
|
}
|
|
337
346
|
}
|
|
338
347
|
|
|
348
|
+
// How many documents cite this one. Measured over docs/ only, which is where
|
|
349
|
+
// the link graph is defined; anything outside it gets `null` — "not measured"
|
|
350
|
+
// and "measured, and the answer is none" are different facts, and rendering
|
|
351
|
+
// the first as the second is the substitution this whole board refuses.
|
|
352
|
+
let inboundBy = null;
|
|
353
|
+
try {
|
|
354
|
+
const g = linkGraph(path.join(root, 'docs'));
|
|
355
|
+
inboundBy = new Map();
|
|
356
|
+
for (const [k, v] of g.inbound) inboundBy.set(path.relative(root, k), v.length);
|
|
357
|
+
} catch { inboundBy = null; }
|
|
358
|
+
|
|
339
359
|
const seen = new Set();
|
|
340
360
|
const docs = [];
|
|
341
361
|
for (const d of found) {
|
|
@@ -353,6 +373,7 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
|
353
373
|
title: (text !== null && titleFromText(text)) || path.basename(d.rel, '.md'),
|
|
354
374
|
group: groupFor(d.rel, { text: text ?? '' }),
|
|
355
375
|
size: d.size,
|
|
376
|
+
inbound: inboundBy ? (inboundBy.has(d.rel) ? inboundBy.get(d.rel) : null) : null,
|
|
356
377
|
modified: d.modified,
|
|
357
378
|
// A modification time answers "when was this file last touched", which is
|
|
358
379
|
// a different question from "is this still true". A typo fix rejuvenates a
|
|
@@ -5402,6 +5402,52 @@ async function refreshDocsCount() {
|
|
|
5402
5402
|
el.textContent = (d && !d.error && typeof d.total === 'number') ? d.total : '—';
|
|
5403
5403
|
}
|
|
5404
5404
|
|
|
5405
|
+
// How many documents cite this one, and the order of the list.
|
|
5406
|
+
//
|
|
5407
|
+
// 105 of 156 documents have no inbound reference. A badge on two rows in three
|
|
5408
|
+
// marks nothing — so the count itself is the signal, and sorting by it answers
|
|
5409
|
+
// "where would I find X" while letting the zeroes surface without being shouted
|
|
5410
|
+
// at. "not measured" is not a zero: it means the document sits outside the link
|
|
5411
|
+
// graph, which is a different fact from being cited by nobody.
|
|
5412
|
+
let _docsSort = 'recent';
|
|
5413
|
+
|
|
5414
|
+
function setDocsSort(mode) {
|
|
5415
|
+
_docsSort = mode;
|
|
5416
|
+
const host = document.getElementById('docs-groups');
|
|
5417
|
+
if (host && _docsCache) host.innerHTML = renderDocGroups(_docsCache.groups || []);
|
|
5418
|
+
}
|
|
5419
|
+
|
|
5420
|
+
function citedLabel(doc) {
|
|
5421
|
+
if (doc.inbound === null || doc.inbound === undefined) {
|
|
5422
|
+
return '<span class="muted absent" title="Outside docs/ — citations were not measured">not measured</span>';
|
|
5423
|
+
}
|
|
5424
|
+
return `<span class="muted">cited by ${doc.inbound}</span>`;
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
function renderDocGroups(groups) {
|
|
5428
|
+
const sorted = (docs) => _docsSort === 'cited'
|
|
5429
|
+
? docs.slice().sort((a, b) => (b.inbound ?? -1) - (a.inbound ?? -1) || a.title.localeCompare(b.title))
|
|
5430
|
+
: docs.slice().sort((a, b) => String(b.modified).localeCompare(String(a.modified)));
|
|
5431
|
+
|
|
5432
|
+
const control = `<div class="card" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
5433
|
+
<b>Sort</b>
|
|
5434
|
+
<button type="button" class="ab-btn" aria-pressed="${_docsSort === 'recent'}" onclick="setDocsSort('recent')">Recently changed</button>
|
|
5435
|
+
<button type="button" class="ab-btn" aria-pressed="${_docsSort === 'cited'}" onclick="setDocsSort('cited')">Most cited</button>
|
|
5436
|
+
<span class="muted" style="font-size:var(--fs-caption)">How many other documents link here.</span>
|
|
5437
|
+
</div>`;
|
|
5438
|
+
|
|
5439
|
+
return control + groups.map(g => `<div class="card">
|
|
5440
|
+
<b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
|
|
5441
|
+
<div style="margin-top:8px">${sorted(g.docs).map(doc => `
|
|
5442
|
+
<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
5443
|
+
<a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
|
|
5444
|
+
<span class="muted" style="font-size:var(--fs-caption)"> · ${esc(doc.path)} · ${ago(doc.modified)} · </span>
|
|
5445
|
+
<span style="font-size:var(--fs-caption)">${citedLabel(doc)}</span>
|
|
5446
|
+
${freshnessBadge(doc)}
|
|
5447
|
+
</div>`).join('')}</div>
|
|
5448
|
+
</div>`).join('');
|
|
5449
|
+
}
|
|
5450
|
+
|
|
5405
5451
|
async function loadDocs() {
|
|
5406
5452
|
const el = document.getElementById('docs-body');
|
|
5407
5453
|
el.innerHTML = '<span class="muted">loading…</span>';
|
|
@@ -5439,15 +5485,7 @@ async function loadDocs() {
|
|
|
5439
5485
|
<details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
|
|
5440
5486
|
<pre style="white-space:pre-wrap;font-size:var(--fs-caption)">${esc(d.map.mermaid)}</pre></details></div>` : '';
|
|
5441
5487
|
|
|
5442
|
-
const groups = (d.groups || [])
|
|
5443
|
-
<b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
|
|
5444
|
-
<div style="margin-top:8px">${g.docs.map(doc => `
|
|
5445
|
-
<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
5446
|
-
<a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
|
|
5447
|
-
<span class="muted" style="font-size:var(--fs-caption)"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
|
|
5448
|
-
${freshnessBadge(doc)}
|
|
5449
|
-
</div>`).join('')}</div>
|
|
5450
|
-
</div>`).join('');
|
|
5488
|
+
const groups = renderDocGroups(d.groups || []);
|
|
5451
5489
|
|
|
5452
5490
|
// Memory used to be its own tab listing PROJECT.md, CODEBASE.md and lessons —
|
|
5453
5491
|
// the same files this panel's "This project" group already showed. One section
|
|
@@ -5457,7 +5495,7 @@ async function loadDocs() {
|
|
|
5457
5495
|
|
|
5458
5496
|
// The document itself opens in the side panel now, so this panel no longer
|
|
5459
5497
|
// needs a place to append one.
|
|
5460
|
-
el.innerHTML = pipeline + map + mem + groups
|
|
5498
|
+
el.innerHTML = pipeline + map + mem + `<div id="docs-groups">${groups}</div>`;
|
|
5461
5499
|
}
|
|
5462
5500
|
|
|
5463
5501
|
/**
|
|
@@ -5526,12 +5564,18 @@ async function loadContextLayers() {
|
|
|
5526
5564
|
}
|
|
5527
5565
|
|
|
5528
5566
|
const layers = (m.layers || []).map(l => {
|
|
5529
|
-
|
|
5530
|
-
|
|
5567
|
+
// The reader sees `displayPath` — `.great_cto/PROJECT.md`, or `~/.great_cto/…`
|
|
5568
|
+
// for a shared layer. The absolute path stays behind openDoc(), because it
|
|
5569
|
+
// names this machine's home directory and username and this screen is
|
|
5570
|
+
// photographed for the README. `scope` comes from the server, which knows;
|
|
5571
|
+
// this used to be guessed by matching the breadcrumb text against the path.
|
|
5572
|
+
const shared = l.scope === 'global';
|
|
5573
|
+
const where = shared ? ' <span class="muted" style="font-size:var(--fs-caption)">shared by every project</span>' : '';
|
|
5574
|
+
const shown = l.displayPath || l.name || l.id;
|
|
5531
5575
|
return l.exists
|
|
5532
5576
|
? `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
5533
5577
|
<a href="#" onclick="openDoc('${esc(l.path)}');return false">${esc(l.id)}</a>${where}
|
|
5534
|
-
<span class="muted" style="font-size:var(--fs-caption)"> · ${esc(
|
|
5578
|
+
<span class="muted" style="font-size:var(--fs-caption)"> · ${esc(shown)}</span></div>`
|
|
5535
5579
|
: `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)" class="muted">
|
|
5536
5580
|
${esc(l.id)} — not written yet${where}</div>`;
|
|
5537
5581
|
}).join('');
|
|
@@ -5547,12 +5591,6 @@ async function loadContextLayers() {
|
|
|
5547
5591
|
<div style="margin-top:8px">${pats}</div></div>` : '');
|
|
5548
5592
|
}
|
|
5549
5593
|
|
|
5550
|
-
/** The selected project's root, for telling a shared layer from a local one. */
|
|
5551
|
-
function projectRootHint() {
|
|
5552
|
-
const crumb = document.querySelector('.crumb-project, #crumb-project');
|
|
5553
|
-
return (crumb && crumb.textContent || '').trim() || '\u0000';
|
|
5554
|
-
}
|
|
5555
|
-
|
|
5556
5594
|
/** The pipeline as boxes and gates — no diagram library, so it always renders. */
|
|
5557
5595
|
// Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
|
|
5558
5596
|
// a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
|
|
@@ -6448,7 +6486,11 @@ function cardHTML(t) {
|
|
|
6448
6486
|
const pIdx = t.priority ?? 3;
|
|
6449
6487
|
const pClass = `p${pIdx}`;
|
|
6450
6488
|
const pLabel = PRIORITY_LABEL[pIdx];
|
|
6451
|
-
|
|
6489
|
+
// The agent is stored in BOTH `agent` and `labels` — deliberately, so filtering
|
|
6490
|
+
// by label finds it — and the card drew a chip from each, so every card carried
|
|
6491
|
+
// its owner's name twice. Keep the data; draw it once. The agent chip is the
|
|
6492
|
+
// one that stays, because it is the one that is styled as an owner.
|
|
6493
|
+
const labels = (t.labels || []).filter(l => l !== 'gate' && l !== t.agent);
|
|
6452
6494
|
const isGate = !!t.is_gate || (t.labels || []).includes('gate');
|
|
6453
6495
|
// raw_status reflects bd's actual status (open/in_progress/closed/blocked) —
|
|
6454
6496
|
// mapStatus() returns 'gate' for gate-labeled cards regardless, so we need
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-links — how much of the documentation is connected to the rest of it.
|
|
3
|
+
*
|
|
4
|
+
* The operator's words: "документация — по-прежнему не связана в единое целое."
|
|
5
|
+
* Measured over `docs/`, excluding machine summaries and translations:
|
|
6
|
+
*
|
|
7
|
+
* 155 documents
|
|
8
|
+
* 89 orphans — link to nothing, and nothing links to them
|
|
9
|
+
* 18 ADRs, every one of which links to another ADR
|
|
10
|
+
*
|
|
11
|
+
* So the corpus is one connected island and a field of loose leaves. `ADR-009` has
|
|
12
|
+
* ten inbound links; eighteen plans in `docs/plans` have none in either direction.
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS DOES NOT TRY TO FIX IT
|
|
15
|
+
* ------------------------------
|
|
16
|
+
* The obvious idea is to derive the links: a PLAN about X should point at the ARCH
|
|
17
|
+
* about X and the QA report about X. Measured before attempting it — across 155
|
|
18
|
+
* documents there are THREE shared slugs, covering six files, and front-matter
|
|
19
|
+
* exists on eight. There is no naming convention to derive from. Two real pairs
|
|
20
|
+
* (`judge-provenance`, `stale-after`) are the exception that shows the rule.
|
|
21
|
+
*
|
|
22
|
+
* Connecting the other 89 means reading and understanding 89 documents. A script
|
|
23
|
+
* that guessed would produce links nobody meant, which is exactly the confident
|
|
24
|
+
* fabrication this project spends its checks removing.
|
|
25
|
+
*
|
|
26
|
+
* So this measures, and it ratchets. The number may not grow. Closing it is
|
|
27
|
+
* authoring work, done deliberately, a few documents at a time.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
/** Generated summaries and translations are copies, not documents. */
|
|
34
|
+
const IS_SUMMARY = /\.summary\.md$/;
|
|
35
|
+
const IS_TRANSLATION = /^docs\/[a-z]{2}(-[A-Z]{2})?\//;
|
|
36
|
+
|
|
37
|
+
export function listDocs(root = 'docs') {
|
|
38
|
+
// The translation rule is written against a path that starts at the docs
|
|
39
|
+
// directory. `root` may be relative ('docs') or absolute (the board serves
|
|
40
|
+
// other projects by absolute path), so every candidate is re-expressed
|
|
41
|
+
// relative to root's parent before the rule is applied — otherwise the rule
|
|
42
|
+
// matches nothing on an absolute walk and translations count as documents,
|
|
43
|
+
// which is a wrong number that looks like a right one.
|
|
44
|
+
const base = path.dirname(root);
|
|
45
|
+
const out = [];
|
|
46
|
+
const walk = (dir) => {
|
|
47
|
+
let entries;
|
|
48
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
const full = path.join(dir, e);
|
|
51
|
+
let st;
|
|
52
|
+
try { st = statSync(full); } catch { continue; }
|
|
53
|
+
if (st.isDirectory()) walk(full);
|
|
54
|
+
else if (e.endsWith('.md') && !IS_SUMMARY.test(e) && !IS_TRANSLATION.test(path.relative(base, full))) out.push(full);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk(root);
|
|
58
|
+
return out.sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {{docs:string[], orphans:string[], inbound:Map<string,string[]>}}
|
|
63
|
+
* An orphan links to no document in this set AND is linked to by none. Both
|
|
64
|
+
* directions matter: a document nobody references is unreachable, and one that
|
|
65
|
+
* references nothing is unplaced.
|
|
66
|
+
*/
|
|
67
|
+
export function linkGraph(root = 'docs', read = readFileSync) {
|
|
68
|
+
const docs = listDocs(root);
|
|
69
|
+
const known = new Set(docs);
|
|
70
|
+
const out = new Map(docs.map((d) => [d, new Set()]));
|
|
71
|
+
const inbound = new Map(docs.map((d) => [d, new Set()]));
|
|
72
|
+
|
|
73
|
+
for (const f of docs) {
|
|
74
|
+
let text = '';
|
|
75
|
+
try { text = String(read(f, 'utf8')); } catch { continue; }
|
|
76
|
+
const add = (target) => {
|
|
77
|
+
if (!known.has(target) || target === f) return;
|
|
78
|
+
out.get(f).add(target);
|
|
79
|
+
inbound.get(target).add(f);
|
|
80
|
+
};
|
|
81
|
+
// Markdown links, resolved relative to the file and to the repo root.
|
|
82
|
+
for (const m of text.matchAll(/\]\(([^)#\s]+\.md)/g)) {
|
|
83
|
+
const rel = m[1];
|
|
84
|
+
add(path.normalize(path.join(path.dirname(f), rel)));
|
|
85
|
+
add(path.normalize(rel.replace(/^\.\.\//, '')));
|
|
86
|
+
add(path.normalize(path.join(root, rel)));
|
|
87
|
+
}
|
|
88
|
+
// `ADR-009` in prose is a reference even without a link — the convention this
|
|
89
|
+
// repository actually uses, and the reason the ADRs are its one connected set.
|
|
90
|
+
for (const m of text.matchAll(/\bADR-(\d{3})\b/g)) {
|
|
91
|
+
for (const c of docs) {
|
|
92
|
+
if (path.basename(c).startsWith(`ADR-${m[1]}`)) add(c);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const orphans = docs.filter((d) => out.get(d).size === 0 && inbound.get(d).size === 0);
|
|
98
|
+
return {
|
|
99
|
+
docs,
|
|
100
|
+
orphans,
|
|
101
|
+
inbound: new Map([...inbound].map(([k, v]) => [k, [...v]])),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -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