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.
@@ -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.17.0",
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
- const gates = tasks.filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked');
238
- for (const g of gates) {
239
- const created = new Date(g.created_at || g.updated_at || 0).getTime();
240
- const ageHr = (Date.now() - created) / 3600_000;
241
- if (ageHr < 2 || ageHr > 24 * 7) continue;
242
- 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);
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)} pending ${ageHr.toFixed(1)}h`,
245
- body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\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}`,
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: { gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h` },
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
- return readDegradation.get(cwd) || bdFailureFor(cwd) || null;
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 || []).map(g => `<div class="card">
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
- const global = String(l.path || '').includes('/.great_cto/') && !String(l.path || '').includes(projectRootHint());
5530
- const where = global ? ' <span class="muted" style="font-size:var(--fs-caption)">shared by every project</span>' : '';
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(l.path)}</span></div>`
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
- const labels = (t.labels || []).filter(l => l !== 'gate');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.17.0",
3
+ "version": "3.19.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",