great-cto 2.96.0 → 2.97.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 +5 -1
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +39 -0
- package/board/packages/board/lib/routes.mjs +107 -0
- package/board/packages/board/public/index.html +194 -4
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/dist/detect.js +1 -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": "2.
|
|
5
|
+
"version": "2.97.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -195,6 +195,10 @@
|
|
|
195
195
|
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/reviewer-nudge.mjs\" 2>/dev/null || true",
|
|
196
196
|
"timeout": 5,
|
|
197
197
|
"statusMessage": "Checking reviewer rules for this file..."
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"type": "command",
|
|
201
|
+
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/lesson-rules-check.mjs\" 2>/dev/null; true"
|
|
198
202
|
}
|
|
199
203
|
]
|
|
200
204
|
},
|
|
@@ -322,17 +322,62 @@ function getInbox(cwd = process.cwd()) {
|
|
|
322
322
|
return ageH > 48;
|
|
323
323
|
});
|
|
324
324
|
const sec = readSecStats(cwd);
|
|
325
|
+
|
|
326
|
+
// One object, one section.
|
|
327
|
+
//
|
|
328
|
+
// These four filters overlap — a P0 that is also blocked matched two of them,
|
|
329
|
+
// so it rendered as two rows and the nav badge (gates + p0 + blocked) counted
|
|
330
|
+
// it twice. "Two things need you" when one thing does is a small lie that
|
|
331
|
+
// costs real attention, and it is the same defect as any other count that
|
|
332
|
+
// measures the query rather than the world.
|
|
333
|
+
//
|
|
334
|
+
// Each task lands in its strongest section only, strongest first: a gate is
|
|
335
|
+
// waiting on a signature, a P0 is an emergency, blocked is a state, stale is
|
|
336
|
+
// an observation. The other states it is in are kept on the row as `also`, so
|
|
337
|
+
// deduplicating loses nothing — the row can still say "and it is blocked".
|
|
338
|
+
const ORDER = [
|
|
339
|
+
['gate', pendingGates],
|
|
340
|
+
['p0', p0],
|
|
341
|
+
['blocked', blocked],
|
|
342
|
+
['stale', stale],
|
|
343
|
+
];
|
|
344
|
+
const homeOf = new Map(); // task id → the section that owns it
|
|
345
|
+
const alsoOf = new Map(); // task id → the other sections it matched
|
|
346
|
+
for (const [name, list] of ORDER) {
|
|
347
|
+
for (const t of list) {
|
|
348
|
+
const id = t?.id ?? t?.title;
|
|
349
|
+
if (id === undefined) continue;
|
|
350
|
+
if (homeOf.has(id)) { alsoOf.set(id, [...(alsoOf.get(id) || []), name]); continue; }
|
|
351
|
+
homeOf.set(id, name);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const own = (name, list, limit) => list
|
|
355
|
+
.filter((t) => homeOf.get(t?.id ?? t?.title) === name)
|
|
356
|
+
.map((t) => ({ ...t, also: alsoOf.get(t?.id ?? t?.title) || [] }))
|
|
357
|
+
.slice(0, limit);
|
|
358
|
+
|
|
359
|
+
const ownedGates = own('gate', pendingGates, 20);
|
|
360
|
+
const ownedP0 = own('p0', p0, 10);
|
|
361
|
+
const ownedBlocked = own('blocked', blocked, 10);
|
|
362
|
+
const ownedStale = own('stale', stale, 10);
|
|
363
|
+
|
|
325
364
|
return {
|
|
326
|
-
pending_gates:
|
|
327
|
-
blocked:
|
|
328
|
-
p0_open:
|
|
329
|
-
stale_in_progress:
|
|
365
|
+
pending_gates: ownedGates,
|
|
366
|
+
blocked: ownedBlocked,
|
|
367
|
+
p0_open: ownedP0,
|
|
368
|
+
stale_in_progress: ownedStale,
|
|
330
369
|
security: { blocked: sec.blocked, approved: sec.approved },
|
|
331
370
|
summary: {
|
|
332
|
-
gates
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
371
|
+
// Counts of distinct objects, so gates + p0 + blocked is a real total
|
|
372
|
+
// rather than a sum over overlapping sets.
|
|
373
|
+
gates: [...homeOf.values()].filter((s) => s === 'gate').length,
|
|
374
|
+
blocked: [...homeOf.values()].filter((s) => s === 'blocked').length,
|
|
375
|
+
p0: [...homeOf.values()].filter((s) => s === 'p0').length,
|
|
376
|
+
stale: [...homeOf.values()].filter((s) => s === 'stale').length,
|
|
377
|
+
// How many distinct things want attention at all — the number the nav
|
|
378
|
+
// badge means, stated once here rather than re-derived by every caller
|
|
379
|
+
// that might re-derive it wrongly.
|
|
380
|
+
needs_you: [...homeOf.values()].filter((s) => s !== 'stale').length,
|
|
336
381
|
},
|
|
337
382
|
};
|
|
338
383
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// Zero dependencies, like the rest of the board.
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
|
+
import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -100,6 +101,38 @@ export function groupFor(rel) {
|
|
|
100
101
|
* differently — the same reason the pipeline view labels a stale verdict rather
|
|
101
102
|
* than hiding it.
|
|
102
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* One document's freshness, as three states rather than a date.
|
|
106
|
+
*
|
|
107
|
+
* `unknown` covers two different absences and says which: a file we could not
|
|
108
|
+
* read at all, and a file that simply declares no date. Neither may render as
|
|
109
|
+
* fresh — the whole reason `stale_after` exists is that a document nobody can
|
|
110
|
+
* judge must not look like one that passed.
|
|
111
|
+
*/
|
|
112
|
+
function freshnessOf(abs, nowMs = Date.now(), staleDays = 180) {
|
|
113
|
+
let text;
|
|
114
|
+
try { text = fs.readFileSync(abs, 'utf8'); }
|
|
115
|
+
catch (e) {
|
|
116
|
+
return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
|
|
117
|
+
freshnessWhy: `could not read this file: ${String(e?.message || e)}` };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const j = judgeFreshness({ text, dateType: 'any', nowMs, staleDays });
|
|
121
|
+
return {
|
|
122
|
+
freshness: j.verdict,
|
|
123
|
+
freshnessBasis: j.basis,
|
|
124
|
+
staleAfter: j.staleAfter,
|
|
125
|
+
freshnessWhy: j.basis === 'declared'
|
|
126
|
+
? `the author declared it good until ${j.staleAfter}`
|
|
127
|
+
: (j.date ? `judged by its own date ${j.date} (${j.ageDays}d, threshold ${staleDays}d)`
|
|
128
|
+
: 'no stale_after and no date — nothing to judge it by'),
|
|
129
|
+
};
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
|
|
132
|
+
freshnessWhy: String(e?.message || e) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
104
137
|
const found = [];
|
|
105
138
|
for (const g of DOC_GROUPS) {
|
|
@@ -125,6 +158,12 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
|
125
158
|
group: groupFor(d.rel),
|
|
126
159
|
size: d.size,
|
|
127
160
|
modified: d.modified,
|
|
161
|
+
// A modification time answers "when was this file last touched", which is
|
|
162
|
+
// a different question from "is this still true". A typo fix rejuvenates a
|
|
163
|
+
// document that stopped being true months earlier, and the list showed
|
|
164
|
+
// only the former. `judgeFreshness` gives three verdicts and names which
|
|
165
|
+
// rule produced each — see scripts/lib/freshness.mjs.
|
|
166
|
+
...freshnessOf(d.abs),
|
|
128
167
|
});
|
|
129
168
|
}
|
|
130
169
|
|
|
@@ -551,6 +551,113 @@ async function dispatch(req, res, url, cwd) {
|
|
|
551
551
|
return true;
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
+
// Receipt — does the approval still apply to what is in the tree?
|
|
555
|
+
//
|
|
556
|
+
// The reviewing agents already record a hash-bound receipt of the bytes they
|
|
557
|
+
// read, and those verdicts are in the log this board reads. Nothing surfaced
|
|
558
|
+
// them, so the top rung of the evidence ladder — the reviewed bytes are the
|
|
559
|
+
// shipped bytes — was being written and never looked at. An approval that no
|
|
560
|
+
// longer describes the tree is the one thing a gate button cannot tell you.
|
|
561
|
+
if (pathname === '/api/receipt') {
|
|
562
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
563
|
+
let out;
|
|
564
|
+
try {
|
|
565
|
+
const { latestApproval, treeReceipt, compareReceipts, mergeBase } = await import('../../../scripts/lib/receipt.mjs');
|
|
566
|
+
const approval = latestApproval(c);
|
|
567
|
+
if (!approval?.receipt) {
|
|
568
|
+
out = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
|
|
569
|
+
} else {
|
|
570
|
+
const current = treeReceipt(c, { base: approval.receipt.base || mergeBase(c) });
|
|
571
|
+
const cmp = compareReceipts(approval.receipt, current, { cwd: c });
|
|
572
|
+
out = { ...cmp, agent: approval.agent, ts: approval.ts };
|
|
573
|
+
}
|
|
574
|
+
} catch (e) {
|
|
575
|
+
// Never "matches" on an error. Not knowing whether the approval still
|
|
576
|
+
// holds is its own state, and calling it a match would put the strongest
|
|
577
|
+
// reassurance exactly where the least is known.
|
|
578
|
+
out = { state: 'unreadable', why: String(e?.message || e), agent: null, ts: null };
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
581
|
+
res.end(JSON.stringify(out));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Gate tiers — will this gate wait for you, or announce and proceed?
|
|
586
|
+
//
|
|
587
|
+
// A gate card offered Approve/Reject regardless, which is the right control
|
|
588
|
+
// for a `gated` gate and the wrong one for a tiered gate that already went
|
|
589
|
+
// ahead. Two states rendered where the machinery has three.
|
|
590
|
+
//
|
|
591
|
+
// `enabled` is returned alongside, because a tier is only in force when the
|
|
592
|
+
// project opted in with `gate-tiering: evidence`. Showing "notify" on a
|
|
593
|
+
// project that never opted in would describe a stand-down that will not
|
|
594
|
+
// happen — the mirror image of the defect this whole feature guards.
|
|
595
|
+
if (pathname === '/api/gate-tiers') {
|
|
596
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
597
|
+
try {
|
|
598
|
+
const { tierAll, tieringEnabled } = await import('../../../scripts/lib/gate-tier.mjs');
|
|
599
|
+
const { fileURLToPath: f2u } = await import('node:url');
|
|
600
|
+
const libDir = path.dirname(f2u(import.meta.url));
|
|
601
|
+
const histPath = path.join(libDir, '..', '..', '..', 'tests', 'eval', 'results-history.jsonl');
|
|
602
|
+
|
|
603
|
+
let enabled = false;
|
|
604
|
+
try { enabled = tieringEnabled(fs.readFileSync(path.join(c, '.great_cto', 'PROJECT.md'), 'utf8')); }
|
|
605
|
+
catch { /* no PROJECT.md — tiering is off, which is the safe reading */ }
|
|
606
|
+
|
|
607
|
+
const rows = [];
|
|
608
|
+
let historyRead = false;
|
|
609
|
+
try {
|
|
610
|
+
for (const line of fs.readFileSync(histPath, 'utf8').split('\n')) {
|
|
611
|
+
if (!line.trim()) continue;
|
|
612
|
+
try { rows.push(JSON.parse(line)); } catch { /* a bad row is not evidence */ }
|
|
613
|
+
}
|
|
614
|
+
historyRead = true;
|
|
615
|
+
} catch { /* reported as unmeasured below, never as gated-because-fine */ }
|
|
616
|
+
|
|
617
|
+
const byAgent = {};
|
|
618
|
+
for (const t of tierAll(rows)) byAgent[t.agent] = { tier: t.tier, why: t.why, evals: t.evals };
|
|
619
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
620
|
+
res.end(JSON.stringify({ enabled, measured: historyRead, agents: byAgent }));
|
|
621
|
+
} catch (e) {
|
|
622
|
+
// The real error. An agent missing from `agents` reads as unmeasured on the
|
|
623
|
+
// client, which keeps its gate — the correct direction to be wrong in.
|
|
624
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
625
|
+
res.end(JSON.stringify({ enabled: false, measured: false, agents: {}, why: String(e?.message || e) }));
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Stand-downs — gates that proceeded WITHOUT being asked, and on what evidence.
|
|
631
|
+
//
|
|
632
|
+
// ADR-009: a gate is not the only valid answer, but silence is never one. An
|
|
633
|
+
// evidence-tiered gate announces instead of waiting, and until now that
|
|
634
|
+
// announcement had nowhere on this board to land — the record was being
|
|
635
|
+
// written and nothing read it.
|
|
636
|
+
if (pathname === '/api/stand-downs') {
|
|
637
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
638
|
+
let rows = null;
|
|
639
|
+
let why = '';
|
|
640
|
+
try {
|
|
641
|
+
const { readStandDowns } = await import('../../../scripts/lib/stand-down.mjs');
|
|
642
|
+
rows = readStandDowns(c, { limit: 50 });
|
|
643
|
+
} catch (e) {
|
|
644
|
+
// The real error. A handler that names a cause it did not observe sends
|
|
645
|
+
// the reader looking in the wrong place — this file has done that once.
|
|
646
|
+
why = String(e?.message || e);
|
|
647
|
+
}
|
|
648
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
649
|
+
// Three states, because two cannot carry them: `readStandDowns` returns null
|
|
650
|
+
// when the ledger exists and could not be read, [] when nothing has stood
|
|
651
|
+
// down, and rows when something has. "I could not look" must never render as
|
|
652
|
+
// "nothing happened".
|
|
653
|
+
res.end(JSON.stringify(
|
|
654
|
+
rows === null
|
|
655
|
+
? { state: 'unreadable', why: why || 'the stand-down ledger exists but could not be read', rows: [] }
|
|
656
|
+
: { state: rows.length ? 'some' : 'none', why: '', rows },
|
|
657
|
+
));
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
554
661
|
// Resume — pick up where you left off (last verdicts + WIP + recent decisions)
|
|
555
662
|
if (pathname === '/api/resume') {
|
|
556
663
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
@@ -2284,6 +2284,9 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2284
2284
|
<div class="inbox-page">
|
|
2285
2285
|
<div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
|
|
2286
2286
|
<div class="inbox-summary" id="inbox-summary"></div>
|
|
2287
|
+
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
2288
|
+
still the bytes in the tree? Hidden while they match. -->
|
|
2289
|
+
<div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:12px"></div>
|
|
2287
2290
|
|
|
2288
2291
|
<!-- Resume — pick up where you left off -->
|
|
2289
2292
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
@@ -2332,6 +2335,17 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2332
2335
|
</div>
|
|
2333
2336
|
<div id="inbox-gates"></div>
|
|
2334
2337
|
</div>
|
|
2338
|
+
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
2339
|
+
you still owe, because "what proceeded without me" is the question a
|
|
2340
|
+
tiered gate creates and nothing on this board answered. -->
|
|
2341
|
+
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
2342
|
+
<div class="inbox-section-head">
|
|
2343
|
+
<span class="dot dot-amber"></span>
|
|
2344
|
+
<h3>Proceeded without you</h3>
|
|
2345
|
+
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
2346
|
+
</div>
|
|
2347
|
+
<div id="inbox-standdown"></div>
|
|
2348
|
+
</div>
|
|
2335
2349
|
<div class="inbox-section" id="inbox-p0-section">
|
|
2336
2350
|
<div class="inbox-section-head">
|
|
2337
2351
|
<span class="dot dot-red"></span>
|
|
@@ -2754,7 +2768,12 @@ function greetByHour() {
|
|
|
2754
2768
|
function renderInbox(d) {
|
|
2755
2769
|
inboxData = d || {};
|
|
2756
2770
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
2757
|
-
|
|
2771
|
+
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
2772
|
+
// anything that matched two of them — a P0 that is also blocked read as two things
|
|
2773
|
+
// needing attention when it was one. Older payloads have no `needs_you`, so the sum
|
|
2774
|
+
// stays as the fallback rather than rendering nothing.
|
|
2775
|
+
document.getElementById('nav-inbox-count').textContent =
|
|
2776
|
+
Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
|
|
2758
2777
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
2759
2778
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
2760
2779
|
const greetTail = anyDegraded()
|
|
@@ -2771,6 +2790,8 @@ function renderInbox(d) {
|
|
|
2771
2790
|
renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', {});
|
|
2772
2791
|
renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
|
|
2773
2792
|
renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
|
|
2793
|
+
refreshStandDowns();
|
|
2794
|
+
refreshReceipt();
|
|
2774
2795
|
// Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
|
|
2775
2796
|
const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
|
|
2776
2797
|
+ (d?.blocked || []).length + (d?.stale_in_progress || []).length;
|
|
@@ -2785,6 +2806,150 @@ function renderInbox(d) {
|
|
|
2785
2806
|
'Some project data could not be read — counts below are incomplete.');
|
|
2786
2807
|
}
|
|
2787
2808
|
|
|
2809
|
+
/**
|
|
2810
|
+
* Does the last approval still describe what is in the tree?
|
|
2811
|
+
*
|
|
2812
|
+
* The reviewing agents already record a hash-bound receipt of the bytes they
|
|
2813
|
+
* read. Nothing on this board looked at it, so the top rung of the evidence
|
|
2814
|
+
* ladder was being written and never read.
|
|
2815
|
+
*
|
|
2816
|
+
* Five readings, and only three of them speak:
|
|
2817
|
+
* differs reviewed files changed after the approval — the actionable one
|
|
2818
|
+
* unreadable we could not tell. Shown, because "cannot check" must never
|
|
2819
|
+
* render as "checked and fine"
|
|
2820
|
+
* extended new files appeared; nothing reviewed was altered — worth a
|
|
2821
|
+
* quiet line, since the addition was never reviewed either
|
|
2822
|
+
* matches silent. A clean state does not need a banner.
|
|
2823
|
+
* no-receipt silent. Nothing has been approved yet; saying so would nag
|
|
2824
|
+
* about a stage that has not happened.
|
|
2825
|
+
*/
|
|
2826
|
+
async function refreshReceipt() {
|
|
2827
|
+
const el = document.getElementById('inbox-receipt');
|
|
2828
|
+
if (!el) return;
|
|
2829
|
+
const d = await api(`/api/receipt${pqs()}`);
|
|
2830
|
+
if (!d || d.state === 'matches' || d.state === 'no-receipt') { el.style.display = 'none'; return; }
|
|
2831
|
+
|
|
2832
|
+
const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
|
|
2833
|
+
const body = d.state === 'differs'
|
|
2834
|
+
? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
|
|
2835
|
+
— the approval no longer describes what is in the tree.
|
|
2836
|
+
<span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
|
|
2837
|
+
: d.state === 'unreadable'
|
|
2838
|
+
? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
|
|
2839
|
+
: `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
|
|
2840
|
+
|
|
2841
|
+
const colour = d.state === 'matches' ? 'var(--text3)' : 'var(--status-gate)';
|
|
2842
|
+
el.style.display = '';
|
|
2843
|
+
el.innerHTML = `<span style="color:${colour}">${body}</span>`;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Is this document still true? — which is not the question `modified` answers.
|
|
2848
|
+
*
|
|
2849
|
+
* Three states, and the two that are not `fresh` look different from each other:
|
|
2850
|
+
*
|
|
2851
|
+
* stale the author's own `stale_after` has passed, or its date is older
|
|
2852
|
+
* than the threshold — say so, loudly, it is the actionable one
|
|
2853
|
+
* unknown nothing to judge it by, OR the file could not be read. Both are
|
|
2854
|
+
* shown, because a document nobody could judge must never render the
|
|
2855
|
+
* same as one that passed. That equivalence is why stale_after exists.
|
|
2856
|
+
* fresh nothing — a clean row should stay quiet
|
|
2857
|
+
*
|
|
2858
|
+
* Absent freshness data (an older board payload) also renders nothing, since
|
|
2859
|
+
* inventing a verdict from a missing field is the defect itself.
|
|
2860
|
+
*/
|
|
2861
|
+
function freshnessBadge(doc) {
|
|
2862
|
+
const v = doc?.freshness;
|
|
2863
|
+
if (!v || v === 'fresh') return '';
|
|
2864
|
+
const unreadable = doc.freshnessBasis === 'unreadable';
|
|
2865
|
+
const colour = v === 'stale' ? 'var(--status-gate)' : 'var(--text3)';
|
|
2866
|
+
const label = v === 'stale' ? 'stale' : (unreadable ? 'could not read' : 'unknown');
|
|
2867
|
+
return `<span title="${esc(doc.freshnessWhy || '')}" style="font-family:var(--mono);font-size:10px;margin-left:6px;padding:0 4px;border:1px solid var(--border);border-radius:3px;color:${colour}">${esc(label)}</span>`;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// Will this gate wait for you, or did the pipeline already go past it?
|
|
2871
|
+
//
|
|
2872
|
+
// `null` until fetched, and it stays null on any failure — every render path
|
|
2873
|
+
// treats "no tier information" as "this gate waits", which is the reading that
|
|
2874
|
+
// keeps a human in the loop when we do not know.
|
|
2875
|
+
let gateTiers = null;
|
|
2876
|
+
async function refreshGateTiers() {
|
|
2877
|
+
const d = await api(`/api/gate-tiers${pqs()}`);
|
|
2878
|
+
gateTiers = d && d.agents ? d : null;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/**
|
|
2882
|
+
* The tier line for a gate card, or '' when it would say nothing true.
|
|
2883
|
+
*
|
|
2884
|
+
* Three readings, not two:
|
|
2885
|
+
* - tiering off for this project, or the agent unmeasured → nothing shown, the
|
|
2886
|
+
* gate waits, which is what the buttons already imply
|
|
2887
|
+
* - `gated` → nothing shown for the same reason
|
|
2888
|
+
* - `notify` / `notify-thin` → said out loud, because the pipeline did NOT
|
|
2889
|
+
* wait for this decision and the Approve button alone implies it did
|
|
2890
|
+
*/
|
|
2891
|
+
function gateTierNote(t) {
|
|
2892
|
+
if (!gateTiers?.enabled || !gateTiers?.measured) return '';
|
|
2893
|
+
const info = gateTiers.agents?.[t.agent];
|
|
2894
|
+
if (!info || info.tier === 'gated') return '';
|
|
2895
|
+
const thin = info.tier === 'notify-thin';
|
|
2896
|
+
const colour = thin ? 'var(--status-gate)' : 'var(--text3)';
|
|
2897
|
+
// `notify-thin` is sound statistics over unmeasured coverage — one eval passed
|
|
2898
|
+
// conclusively, which says nothing about the rest of what this agent is for.
|
|
2899
|
+
// It is the reading most worth a second look, so it reads differently.
|
|
2900
|
+
const caveat = thin ? ' — one eval, coverage unmeasured' : '';
|
|
2901
|
+
return `<div style="margin-top:3px;font-size:11px;color:${colour}">
|
|
2902
|
+
<span style="font-family:var(--mono);border:1px solid var(--border);border-radius:3px;padding:0 4px">${esc(info.tier)}</span>
|
|
2903
|
+
the pipeline did not wait for this${esc(caveat)} · <span title="${esc(info.why || '')}">${esc((info.why || '').slice(0, 90))}</span>
|
|
2904
|
+
</div>`;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Gates that announced instead of waiting.
|
|
2908
|
+
//
|
|
2909
|
+
// Three states, not two. `none` hides the section — nothing has proceeded
|
|
2910
|
+
// unattended, which is a real and common answer. `unreadable` SHOWS it and says
|
|
2911
|
+
// so: a ledger that exists and could not be read must never render the same as
|
|
2912
|
+
// an empty one, because the whole reason this record exists is that a
|
|
2913
|
+
// stand-down nobody can see is indistinguishable from a gate nobody configured.
|
|
2914
|
+
async function refreshStandDowns() {
|
|
2915
|
+
const section = document.getElementById('inbox-standdown-section');
|
|
2916
|
+
const root = document.getElementById('inbox-standdown');
|
|
2917
|
+
const cnt = document.getElementById('inbox-standdown-count');
|
|
2918
|
+
if (!section || !root) return;
|
|
2919
|
+
|
|
2920
|
+
const d = await api(`/api/stand-downs${pqs()}`);
|
|
2921
|
+
if (!d) { section.style.display = 'none'; return; }
|
|
2922
|
+
|
|
2923
|
+
if (d.state === 'unreadable') {
|
|
2924
|
+
section.style.display = '';
|
|
2925
|
+
if (cnt) cnt.textContent = '?';
|
|
2926
|
+
root.innerHTML = `<div class="inbox-row"><span class="ttl">The stand-down ledger could not be read
|
|
2927
|
+
<span class="meta">${esc(d.why || '')} — gates may have proceeded unattended without appearing here</span></span></div>`;
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (d.state !== 'some' || !d.rows.length) { section.style.display = 'none'; root.innerHTML = ''; return; }
|
|
2931
|
+
|
|
2932
|
+
section.style.display = '';
|
|
2933
|
+
if (cnt) cnt.textContent = d.rows.length;
|
|
2934
|
+
// Newest first — the ledger appends, but a reader wants the most recent thing
|
|
2935
|
+
// that happened without them.
|
|
2936
|
+
root.innerHTML = d.rows.slice().reverse().map(r => {
|
|
2937
|
+
// `notify-thin` means the gate stood down on ONE eval: sound statistics,
|
|
2938
|
+
// unmeasured coverage. That is the row most worth a second look, so it is
|
|
2939
|
+
// the row that looks different.
|
|
2940
|
+
const thin = r.tier === 'notify-thin';
|
|
2941
|
+
const tierTag = `<span style="font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:${thin ? 'var(--status-gate)' : 'var(--text3)'}">${esc(r.tier || 'unknown')}</span>`;
|
|
2942
|
+
return `
|
|
2943
|
+
<div class="inbox-row">
|
|
2944
|
+
<span class="id">gate:${esc(r.gate || '?')}</span>
|
|
2945
|
+
<span class="ttl">${esc(r.agent || 'unknown agent')} proceeded without asking
|
|
2946
|
+
<span class="ttl-desc">${esc(r.evidence || '')}</span>
|
|
2947
|
+
<span class="meta">${tierTag} · <span title="${esc(r.ts || '')}">${r.ts ? relTime(r.ts) : 'no timestamp'}</span></span>
|
|
2948
|
+
</span>
|
|
2949
|
+
</div>`;
|
|
2950
|
+
}).join('');
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2788
2953
|
function renderInboxList(rootId, items, countId, opts = {}) {
|
|
2789
2954
|
const root = document.getElementById(rootId);
|
|
2790
2955
|
const cnt = document.getElementById(countId);
|
|
@@ -2794,9 +2959,17 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2794
2959
|
if (section) section.style.display = items.length ? '' : 'none';
|
|
2795
2960
|
if (!items.length) { root.innerHTML = ''; return; }
|
|
2796
2961
|
root.innerHTML = items.map(t => {
|
|
2962
|
+
// A task now appears in one section only, so the states it ALSO matched are
|
|
2963
|
+
// shown here rather than lost. Deduplicating the rows must not deduplicate
|
|
2964
|
+
// the facts: a gate that is also blocked is still blocked, and hiding that
|
|
2965
|
+
// to fix a double count would trade one wrong reading for another.
|
|
2966
|
+
const alsoTag = (t.also || []).length
|
|
2967
|
+
? `<span title="also matched: ${esc((t.also || []).join(', '))}" style="color:var(--text3)">also ${esc((t.also || []).join(' + '))}</span>`
|
|
2968
|
+
: '';
|
|
2797
2969
|
const meta = [
|
|
2798
2970
|
t.agent ? `agent: ${esc(t.agent)}` : '',
|
|
2799
2971
|
t.priority != null ? PRIORITY_LABEL[t.priority] : '',
|
|
2972
|
+
alsoTag,
|
|
2800
2973
|
// Gates (the showApprove list) get the exact date+time the decision has been
|
|
2801
2974
|
// waiting since; other rows keep the compact relative stamp.
|
|
2802
2975
|
t.updated_at
|
|
@@ -2812,16 +2985,21 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2812
2985
|
</div>`
|
|
2813
2986
|
: `<div class="actions"><span style="font-family:var(--mono);font-size:11px;color:var(--text3)">${t.status || ''}</span></div>`;
|
|
2814
2987
|
const desc = t.description ? `<span class="ttl-desc">${esc(t.description.slice(0, 140))}${t.description.length > 140 ? '…' : ''}</span>` : '';
|
|
2988
|
+
// Only gate rows can stand down, so only they carry the note.
|
|
2989
|
+
const tierNote = opts.showApprove ? gateTierNote(t) : '';
|
|
2815
2990
|
return `
|
|
2816
2991
|
<div class="inbox-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
2817
2992
|
<span class="id">${esc(t.id || '')}</span>
|
|
2818
|
-
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span
|
|
2993
|
+
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
2819
2994
|
${actions}
|
|
2820
2995
|
</div>`;
|
|
2821
2996
|
}).join('');
|
|
2822
2997
|
}
|
|
2823
2998
|
|
|
2824
2999
|
async function refreshInbox() {
|
|
3000
|
+
// Tiers first: the gate rows are rendered from this same call, and a row drawn
|
|
3001
|
+
// before the tiers arrive would silently claim every gate is waiting.
|
|
3002
|
+
await refreshGateTiers();
|
|
2825
3003
|
const d = await api(`/api/inbox${pqs()}`);
|
|
2826
3004
|
if (d) renderInbox(d);
|
|
2827
3005
|
refreshPipeline();
|
|
@@ -3458,7 +3636,7 @@ async function loadDocs() {
|
|
|
3458
3636
|
|
|
3459
3637
|
const pipeline = d.pipeline
|
|
3460
3638
|
? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
|
|
3461
|
-
${
|
|
3639
|
+
${renderDeclaredPipeline(d.pipeline)}
|
|
3462
3640
|
<details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
|
|
3463
3641
|
<pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
|
|
3464
3642
|
: '';
|
|
@@ -3481,6 +3659,7 @@ async function loadDocs() {
|
|
|
3481
3659
|
<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
3482
3660
|
<a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
|
|
3483
3661
|
<span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
|
|
3662
|
+
${freshnessBadge(doc)}
|
|
3484
3663
|
</div>`).join('')}</div>
|
|
3485
3664
|
</div>`).join('');
|
|
3486
3665
|
|
|
@@ -3541,7 +3720,18 @@ function projectRootHint() {
|
|
|
3541
3720
|
}
|
|
3542
3721
|
|
|
3543
3722
|
/** The pipeline as boxes and gates — no diagram library, so it always renders. */
|
|
3544
|
-
|
|
3723
|
+
// Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
|
|
3724
|
+
// a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
|
|
3725
|
+
// at the top level of this one <script>, so the later declaration won and every
|
|
3726
|
+
// call carrying an array of stages — refreshPipeline() and the SSE handler —
|
|
3727
|
+
// reached this mermaid parser instead. It returned a string nobody used and
|
|
3728
|
+
// never touched `#pipeline-track`, so the rail stayed the empty div in the
|
|
3729
|
+
// markup and the status stayed the hardcoded `idle` beside it.
|
|
3730
|
+
//
|
|
3731
|
+
// The first section of the first screen therefore read "idle" whatever the
|
|
3732
|
+
// pipeline was doing: not an empty state, but a confident wrong answer, which is
|
|
3733
|
+
// the failure this project spends its time removing.
|
|
3734
|
+
function renderDeclaredPipeline(mermaid) {
|
|
3545
3735
|
const edges = [];
|
|
3546
3736
|
for (const line of String(mermaid).split('\n')) {
|
|
3547
3737
|
const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);
|