great-cto 2.96.0 → 2.98.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 +438 -18
- 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.98.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' }));
|