great-cto 3.16.0 → 3.18.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.16.0",
5
+ "version": "3.18.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that always exits non-zero, for testing the
3
+ # failure path of warmTasksAsync (packages/board/beads-warm-async.test.mjs).
4
+ if [ "$1" = "--version" ]; then
5
+ echo "bd-fake 0.0.0"
6
+ exit 0
7
+ fi
8
+ echo "dolt: database is locked" >&2
9
+ exit 1
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that sleeps before answering, so the
3
+ # boot-responsiveness tests can prove the board stays answerable DURING a
4
+ # slow `bd list` — not just that the code calls the right function. Loaded
5
+ # only via GREAT_CTO_BD_BIN in packages/board/*.test.mjs; never used outside
6
+ # tests.
7
+ if [ "$1" = "--version" ]; then
8
+ echo "bd-fake 0.0.0"
9
+ exit 0
10
+ fi
11
+ sleep "${FAKE_BD_DELAY_SECS:-3}"
12
+ echo "[]"
@@ -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,18 +8,25 @@ 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';
11
12
  import { listProjects, readProjectMd } from './projects.mjs';
12
13
  import { addNotification } from './notifications.mjs';
13
14
  import { getMetrics } from './metrics.mjs';
14
15
  import { getCostHistory, getInbox } from './data-readers.mjs';
15
- // Every sweep below runs over EVERY registered project, and `bd list` is
16
- // spawnSync the event loop is held for the whole of it. At 16 projects and
17
- // 2-6 s each that is ~60 s per sweep, and three of these fire every five
18
- // minutes. Sweeps therefore read at sweep freshness, not interactive freshness:
19
- // see SWEEP_MAX_AGE_MS in beads.mjs for the arithmetic that made the board
20
- // unanswerable while reporting "live · synced just now".
16
+ // Every sweep below runs over EVERY registered project. `sweep: true` opts a
17
+ // COLD project (never read this process) into bdList()'s non-blocking fill
18
+ // path instead of its synchronous one the same escape hatch a WARM-but-
19
+ // stale entry already gets. Without it: a real board's first cron tick
20
+ // (t=+5:10, no request in flight) lagged the event loop 9.8s reading a
21
+ // single project synchronously; at 16 projects and 2-6s each that is ~60s
22
+ // per sweep, three of which fire every five minutes, indefinitely. Sweeps
23
+ // read at sweep freshness, not interactive freshness — see SWEEP_MAX_AGE_MS
24
+ // in beads.mjs for the arithmetic that made the board unanswerable while
25
+ // reporting "live · synced just now". `sweep: true` must NEVER be added to
26
+ // an interactive read's opts: an ordinary request is someone waiting on
27
+ // THIS project's answer, and the fill path answers `[]` instead.
21
28
  import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
22
- const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
29
+ const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
23
30
  import { readVerdicts } from './verdicts.mjs';
24
31
  import { isFailure } from './fleet.mjs';
25
32
  import { getShareState, toggleShare } from './share.mjs';
@@ -37,6 +44,22 @@ function readAlertsFired() {
37
44
  try { return JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8')); } catch { return {}; }
38
45
  }
39
46
 
47
+ /**
48
+ * The same file, read as HISTORY rather than as a dedupe set.
49
+ *
50
+ * readAlertsFired answers "have I already sent this instance", and `{}` on
51
+ * failure is right for that: not knowing must never block a send. Read as
52
+ * history, that same `{}` says "this has never happened here" — a claim nobody
53
+ * checked. `null` keeps the two apart.
54
+ */
55
+ function readAlertsHistory() {
56
+ try {
57
+ if (!fs.existsSync(ALERTS_FIRED_PATH)) return null;
58
+ const parsed = JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8'));
59
+ return parsed && typeof parsed === 'object' ? parsed : null;
60
+ } catch { return null; }
61
+ }
62
+
40
63
  function writeAlertsFired(map) {
41
64
  try {
42
65
  if (!fs.existsSync(GREAT_CTO_DIR)) fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
@@ -234,14 +257,24 @@ function startAlertCron() {
234
257
  const ageHr = (Date.now() - created) / 3600_000;
235
258
  if (ageHr < 2 || ageHr > 24 * 7) continue;
236
259
  const dedupeKey = `gate.stale:${proj.slug}:${g.id}`;
260
+ // How often this has happened here. A threshold says the gate is old;
261
+ // only the history says whether old gates are this project's normal
262
+ // state. Both readings come from the same file — one as a dedupe set,
263
+ // this one as history.
264
+ const seen = recurrence(readAlertsHistory(), { event: 'gate.stale', project: proj.slug });
237
265
  const stalePayload = {
238
266
  title: `${proj.slug} — ${g.title.slice(0, 60)} pending ${ageHr.toFixed(1)}h`,
239
- body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\n\nGate: ${g.id}\nProject: ${proj.slug}`,
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}`,
240
268
  level: 'warning',
241
269
  project: proj.slug,
242
270
  link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(g.id)}#inbox`,
243
271
  action: 'Approve in board',
244
- kv: { gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h` },
272
+ kv: {
273
+ gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h`,
274
+ // `unknown` is carried through rather than rendered as 0 — a count
275
+ // nobody took is not a count of none.
276
+ seen_before: seen.count === null ? 'unknown' : `${seen.atLeast ? '\u2265' : ''}${seen.count}`,
277
+ },
245
278
  };
246
279
  fireEmailAlert('gate.stale', dedupeKey, stalePayload);
247
280
  addNotification('gate.stale', stalePayload, dedupeKey);
@@ -38,6 +38,15 @@ import { log } from './log.mjs';
38
38
  // because each request arrived just after the entry expired.
39
39
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
40
 
41
+ /**
42
+ * When a cwd was last told "your cache entry is wrong" — by an invalidate or
43
+ * a stale-mark, whichever most recently ran. A background fetch that STARTED
44
+ * before this timestamp answers a question nobody is asking anymore: see
45
+ * `bdRefreshAsync`, which compares its own start time against this map before
46
+ * committing its result, so a slow fetch racing a write always loses to it.
47
+ */
48
+ const lastInvalidatedAt = new Map();
49
+
41
50
  /**
42
51
  * Drop a directory's entry so the next read is guaranteed fresh.
43
52
  *
@@ -47,6 +56,7 @@ const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
47
56
  */
48
57
  function bdCacheInvalidate(cwd) {
49
58
  clearSelfTouch(cwd);
59
+ lastInvalidatedAt.set(cwd, Date.now());
50
60
  bdCache.delete(cwd);
51
61
  }
52
62
 
@@ -63,6 +73,7 @@ function bdCacheInvalidate(cwd) {
63
73
  */
64
74
  function bdCacheStale(cwd) {
65
75
  clearSelfTouch(cwd);
76
+ lastInvalidatedAt.set(cwd, Date.now());
66
77
  const cached = bdCache.get(cwd);
67
78
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
68
79
  }
@@ -290,13 +301,16 @@ const refreshing = new Set();
290
301
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
291
302
  * moved the first stall out of sight; this removes the rest.
292
303
  *
293
- * Nothing awaits it. It exists to make the NEXT read fast, and a caller that
294
- * needed the new data would have had to block for it anyway.
304
+ * `onDone`, if given, fires exactly once at every exit point (success,
305
+ * failure, or "already refreshing"). Every existing caller omits it and stays
306
+ * fire-and-forget — the ONE caller that wants to know when the fill finished
307
+ * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
295
308
  */
296
- function bdRefreshAsync(cwd) {
297
- if (refreshing.has(cwd)) return;
309
+ function bdRefreshAsync(cwd, onDone = () => {}) {
310
+ if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
298
311
  refreshing.add(cwd);
299
- lastBdRunAt.set(cwd, Date.now());
312
+ const startedAt = Date.now();
313
+ lastBdRunAt.set(cwd, startedAt);
300
314
  let out = '';
301
315
  try {
302
316
  const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
@@ -304,31 +318,87 @@ function bdRefreshAsync(cwd) {
304
318
  child.on('error', (e) => {
305
319
  refreshing.delete(cwd);
306
320
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
321
+ onDone({ ok: false });
307
322
  });
308
323
  child.on('close', (code) => {
309
324
  refreshing.delete(cwd);
310
325
  lastBdRunAt.set(cwd, Date.now());
311
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
326
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
312
327
  try {
313
328
  const parsed = JSON.parse(out || '[]');
314
329
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
315
330
  // and a non-array rendered as no tasks is the silent zero again.
316
331
  if (!Array.isArray(parsed)) {
317
332
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
333
+ onDone({ ok: false });
334
+ return;
335
+ }
336
+ // This fetch answers "what was true at `startedAt`?" — if anyone has
337
+ // since told the cache it's wrong (a write's invalidate/stale-mark)
338
+ // or already written something newer (a synchronous cold read that
339
+ // raced us and won), our answer is superseded. Committing it now
340
+ // would silently resurrect data from before a change a caller is
341
+ // already waiting to see reflected — exactly what made
342
+ // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
343
+ // this function started running from a COLD cache (the boot warm-up)
344
+ // instead of only ever refreshing an already-populated one.
345
+ const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
346
+ const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
347
+ if (supersededByInvalidation || supersededByFresherEntry) {
348
+ onDone({ ok: true, count: parsed.length, discarded: true });
318
349
  return;
319
350
  }
320
351
  bdFailures.delete(cwd);
321
352
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
353
+ onDone({ ok: true, count: parsed.length });
322
354
  } catch (e) {
323
355
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
356
+ onDone({ ok: false });
324
357
  }
325
358
  });
326
359
  } catch (e) {
327
360
  refreshing.delete(cwd);
328
361
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
362
+ onDone({ ok: false });
329
363
  }
330
364
  }
331
365
 
366
+ /**
367
+ * Warm a directory's task cache at boot, WITHOUT blocking the event loop.
368
+ *
369
+ * The boot warm-up used to call `getTasks(cwd)` directly — `bdList()`'s
370
+ * COLD-cache path, which is `bd()`'s synchronous `spawnSync`, because a cold
371
+ * cache has nothing to serve while a background refresh runs, so it has
372
+ * always been the one path in this file that blocks. That's the right call
373
+ * for a REQUEST (something is waiting and there's no stale data to fall back
374
+ * on); it was the wrong call at boot, because a boot warm-up has nobody
375
+ * waiting on it — its only job is to be ready before someone asks.
376
+ *
377
+ * Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN pointed at a
378
+ * fixture that sleeps N seconds before answering): the board bound its port,
379
+ * then answered `000` on EVERY endpoint — including /api/version, a plain
380
+ * readdirSync with nothing to do with bd — for the fixture's entire delay.
381
+ * The port being open did not matter; the single process serving it was
382
+ * blocked inside `spawnSync` the whole time. `bd()`'s own 8s spawnSync
383
+ * timeout capped that run at ~8s; a real cold Dolt store or lock contention
384
+ * has no such cap, which is consistent with the up-to-2-minute stalls
385
+ * reported in production.
386
+ *
387
+ * This calls `bdRefreshAsync` — the SAME `spawn`-based, non-blocking path
388
+ * already used for warm-cache "stale-while-revalidate" reads — for the cold
389
+ * boot case too. The returned Promise exists only so the boot log can report
390
+ * how long the fill took; nothing waits on it, and the server is accepting
391
+ * and answering connections for the entire duration either way.
392
+ *
393
+ * @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, ms: number}>}
394
+ */
395
+ function warmTasksAsync(cwd) {
396
+ const t0 = Date.now();
397
+ return new Promise((resolve) => {
398
+ bdRefreshAsync(cwd, (result) => resolve({ ...result, ms: Date.now() - t0 }));
399
+ });
400
+ }
401
+
332
402
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
333
403
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
334
404
  const cached = bdCache.get(cwd);
@@ -338,14 +408,7 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
338
408
  if (cached && Date.now() - cached.ts < ttl) return cached.data;
339
409
 
340
410
  // Stale-while-revalidate. An entry that exists is served immediately, however
341
- // old, and refreshed off the event loop. Only a directory with NO entry at all
342
- // blocks — which after the boot warm-up means a project nobody has opened yet,
343
- // once.
344
- //
345
- // The alternative was making this async and every caller with it: getTasks,
346
- // getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
347
- // sweeps. That refactor is the correct end state and is not what a board
348
- // hanging today needs.
411
+ // old, and refreshed off the event loop.
349
412
  //
350
413
  // The injected `runner` is how the tests drive this. When one is supplied the
351
414
  // sync path is kept, so a test that stubs `bd` still observes the call it
@@ -354,6 +417,32 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
354
417
  bdRefreshAsync(cwd);
355
418
  return cached.data;
356
419
  }
420
+
421
+ // A directory with NO entry at all — genuinely never read. Left
422
+ // synchronous for an ordinary caller: a request FOR this project is
423
+ // waiting on the answer, and returning `[]` while a background fill
424
+ // catches up would be wrong data handed to a caller who cannot tell it
425
+ // apart from "no tasks" — confirmed the hard way. This branch originally
426
+ // covered every caller, and tests/board-gate.test.mjs,
427
+ // tests/pipeline-e2e.test.mjs and tests/resume-e2e.test.mjs all went red:
428
+ // a gate approved seconds after project creation read back as "no gates",
429
+ // because the read that should have shown it took the async path and
430
+ // answered empty instead.
431
+ //
432
+ // `opts.sweep` is the one caller allowed to skip the wait: the alert cron,
433
+ // which already has its own looser freshness contract (SWEEP_MAX_AGE_MS)
434
+ // and iterates EVERY registered project on a timer nobody is blocking on.
435
+ // Measured live, unprompted: a real board's first cron tick (t=+5:10, no
436
+ // request in flight) lagged the event loop 9.8s doing exactly this
437
+ // synchronous cold read, once per registered project, every five minutes,
438
+ // indefinitely. For that caller specifically, the same non-blocking fill
439
+ // stale-while-revalidate already uses for a WARM entry is safe on a COLD
440
+ // one too — the cron does not need this tick's answer to be correct, only
441
+ // bounded in staleness, which the background fill still gives it.
442
+ if (!cached && runner === bd && opts.sweep) {
443
+ bdRefreshAsync(cwd);
444
+ return [];
445
+ }
357
446
  try {
358
447
  lastBdRunAt.set(cwd, Date.now());
359
448
  const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
@@ -593,7 +682,22 @@ function getReadDegradation(cwd = process.cwd()) {
593
682
  // tasks.md first: if that file exists and is broken, that is the specific
594
683
  // problem. Otherwise report bd's failure, which until now was swallowed — the
595
684
  // board answered "no tasks" for a project whose database bd refused to open.
596
- 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;
597
701
  }
598
702
 
599
703
  function parseTasksMd(cwd) {
@@ -743,6 +847,7 @@ export {
743
847
  checkBeadsAvailable,
744
848
  bdWriteSerialised,
745
849
  bdList,
850
+ warmTasksAsync,
746
851
  bdFailureFor,
747
852
  parseTasksMd,
748
853
  getReadDegradation,
@@ -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