great-cto 3.26.1 → 3.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/fleet.mjs +43 -1
- package/board/packages/board/lib/routes.mjs +20 -7
- package/board/packages/board/public/index.html +83 -17
- package/board/scripts/lib/agent-posture.mjs +266 -0
- package/board/scripts/lib/doc-links.mjs +43 -5
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.26.
|
|
4
|
+
"version": "3.26.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { postureOf } from '../../../scripts/lib/agent-posture.mjs';
|
|
3
4
|
import os from 'os';
|
|
4
5
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
5
6
|
import { readFileSafe } from './util.mjs';
|
|
@@ -128,12 +129,34 @@ function getAgentsFleet(projectCwd) {
|
|
|
128
129
|
const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
|
|
129
130
|
const DEFAULT_TASK_MIN = 30;
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The agent's tool grant, in the language of consequence.
|
|
134
|
+
*
|
|
135
|
+
* FOUR states, because three of them are not "no grant": an unreadable agent
|
|
136
|
+
* file, a file with no `tools:` line, a grant that is entirely routine, and a
|
|
137
|
+
* grant that holds something expensive to undo. The board rendered all of them
|
|
138
|
+
* identically before, because it never saw any of them.
|
|
139
|
+
*/
|
|
140
|
+
function posture(toolsLine, fileReadable) {
|
|
141
|
+
if (!fileReadable) return { state: 'unreadable', expensive: [], scopedInNameOnly: [], why: 'the agent file could not be read' };
|
|
142
|
+
if (toolsLine == null) return { state: 'undeclared', expensive: [], scopedInNameOnly: [], why: 'the agent declares no tools: line' };
|
|
143
|
+
const r = postureOf(toolsLine);
|
|
144
|
+
return {
|
|
145
|
+
state: r.unknownTools.length ? 'unclassified' : r.expensive.length ? 'expensive' : 'routine',
|
|
146
|
+
expensive: r.expensive,
|
|
147
|
+
scopedInNameOnly: r.scopedInNameOnly,
|
|
148
|
+
unknownTools: r.unknownTools,
|
|
149
|
+
why: r.unknownTools.length ? `unclassified grant(s): ${r.unknownTools.join(', ')}` : '',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
for (const f of files) {
|
|
132
154
|
const slug = f.replace(/^great_cto-/, '').replace(/\.md$/, '');
|
|
133
155
|
const fp = path.join(AGENTS_DIR, f);
|
|
134
156
|
const raw = readFileSafe(fp) || '';
|
|
135
157
|
const descM = raw.match(/^description:\s*"?([^"\n]+)"?/m);
|
|
136
158
|
const modelM = raw.match(/^model:\s*(\S+)/m);
|
|
159
|
+
const toolsM = raw.match(/^tools:\s*(.*)$/m);
|
|
137
160
|
const colorM = raw.match(/^color:\s*(\S+)/m);
|
|
138
161
|
|
|
139
162
|
const vs = byAgent.get(slug) || [];
|
|
@@ -155,6 +178,11 @@ function getAgentsFleet(projectCwd) {
|
|
|
155
178
|
// Estimated cost — DEFAULT_TASK_MIN per verdict (no real timing data here).
|
|
156
179
|
const estLlmUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * LLM_RATE_PER_HR;
|
|
157
180
|
const estHumanUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * HUMAN_RATE_PER_HR;
|
|
181
|
+
// NOT a measurement. Both sides are runs x DEFAULT_TASK_MIN x a rate, so this
|
|
182
|
+
// ratio is HUMAN_RATE/LLM_RATE for every agent that ran at all — 500 by
|
|
183
|
+
// construction. metrics.mjs nulls its equivalent for exactly this reason;
|
|
184
|
+
// this one shipped as a per-agent number and read like one. Kept, because
|
|
185
|
+
// removing a field breaks the board, but labelled at the source.
|
|
158
186
|
const savingsX = estLlmUsd > 0 ? Math.round(estHumanUsd / estLlmUsd) : null;
|
|
159
187
|
const realLlmUsd = vs30d.reduce((s, v) => s + (v.cost_usd || 0), 0);
|
|
160
188
|
|
|
@@ -167,7 +195,18 @@ function getAgentsFleet(projectCwd) {
|
|
|
167
195
|
agents.push({
|
|
168
196
|
slug,
|
|
169
197
|
description: descM?.[1]?.trim() || '',
|
|
170
|
-
model: modelM
|
|
198
|
+
// THREE states, not a default. `model: modelM || 'sonnet'` reported every
|
|
199
|
+
// agent with no `model:` line as pinned to sonnet, so "pinned to sonnet"
|
|
200
|
+
// and "not pinned at all" were the same string — and an unreadable agent
|
|
201
|
+
// file produced the same answer a third time. The fleet cannot show what
|
|
202
|
+
// it cannot distinguish.
|
|
203
|
+
model: raw ? (modelM?.[1]?.trim() ?? null) : null,
|
|
204
|
+
model_state: !raw ? 'unreadable' : modelM ? 'pinned' : 'undeclared',
|
|
205
|
+
// The tool grant, named in the language of consequence rather than listed.
|
|
206
|
+
// `/api/agents-installed` did not read `tools:` at all, so the board had
|
|
207
|
+
// nowhere to get it — scripts/lib/agent-posture.mjs has classified these
|
|
208
|
+
// since 3.24.0 and nothing was consuming it.
|
|
209
|
+
posture: posture(toolsM?.[1] ?? null, Boolean(raw)),
|
|
171
210
|
color: colorM?.[1]?.trim() || null,
|
|
172
211
|
domain: deriveDomain(slug),
|
|
173
212
|
runs_total: vs.length,
|
|
@@ -182,6 +221,9 @@ function getAgentsFleet(projectCwd) {
|
|
|
182
221
|
human_usd_30d_est: Math.round(estHumanUsd),
|
|
183
222
|
llm_usd_30d_real: realLlmUsd > 0 ? Math.round(realLlmUsd * 100) / 100 : null,
|
|
184
223
|
savings_x: savingsX,
|
|
224
|
+
// 'ratio' = the rate ratio, identical for every agent. 'measured' would
|
|
225
|
+
// require per-run timing, which no path produces today.
|
|
226
|
+
savings_source: savingsX == null ? null : 'ratio',
|
|
185
227
|
health,
|
|
186
228
|
retired: isRetired(slug),
|
|
187
229
|
// Four states, and only `exceeded` can hold a dispatch — see
|
|
@@ -1518,14 +1518,26 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1518
1518
|
const tasks = getTasks(cwd);
|
|
1519
1519
|
const nowMs = Date.now();
|
|
1520
1520
|
const STUCK_H = 48;
|
|
1521
|
-
|
|
1522
|
-
|
|
1521
|
+
// `stuck` was ALWAYS EMPTY and had been since it was written. It read
|
|
1522
|
+
// `t.startedAt`, a field no code path in this repository produces — a task
|
|
1523
|
+
// carries created_at / updated_at / closed_at and nothing else. Every row
|
|
1524
|
+
// got `age_h: null` and was removed by the filter below, so the panel
|
|
1525
|
+
// reported "nothing is stuck" about a question it never asked. There are
|
|
1526
|
+
// seven in-progress tasks here as this is written.
|
|
1527
|
+
//
|
|
1528
|
+
// `updated_at` is the honest proxy: in progress, and unchanged for STUCK_H.
|
|
1529
|
+
// A task whose age cannot be determined is COUNTED, not dropped — an
|
|
1530
|
+
// unmeasurable task is not a healthy one.
|
|
1531
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1532
|
+
let stuckUnmeasurable = 0;
|
|
1533
|
+
const stuck = inProgress
|
|
1523
1534
|
.map(t => {
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1535
|
+
const since = t.updated_at || t.created_at || null;
|
|
1536
|
+
const ms = since ? new Date(since).getTime() : NaN;
|
|
1537
|
+
if (!Number.isFinite(ms)) { stuckUnmeasurable += 1; return null; }
|
|
1538
|
+
return { id: t.id, title: t.title, agent: t.agent, age_h: Math.round((nowMs - ms) / 3600000), since };
|
|
1527
1539
|
})
|
|
1528
|
-
.filter(t => t
|
|
1540
|
+
.filter(t => t && t.age_h > STUCK_H);
|
|
1529
1541
|
|
|
1530
1542
|
// Per-agent budgets from PROJECT.md
|
|
1531
1543
|
const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
|
|
@@ -1583,7 +1595,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1583
1595
|
// line the parser could not read is reported rather than dropped, because a
|
|
1584
1596
|
// budget silently ignored is a limit its author believes they have.
|
|
1585
1597
|
res.end(JSON.stringify({
|
|
1586
|
-
stuck,
|
|
1598
|
+
stuck, stuck_in_progress: inProgress.length, stuck_unmeasurable: stuckUnmeasurable,
|
|
1599
|
+
budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
|
|
1587
1600
|
budgets_deprecated_key: budgetsDeprecatedKey,
|
|
1588
1601
|
budgets_malformed: budgetsMalformed,
|
|
1589
1602
|
// Three states, not two: read / absent / unreadable. Without these, a
|
|
@@ -577,6 +577,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
577
577
|
}
|
|
578
578
|
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--status-review); }
|
|
579
579
|
.live-dot.error { background: var(--status-blocked); }
|
|
580
|
+
/* Neither live nor failed: the handshake has not resolved. Three states. */
|
|
581
|
+
.live-dot.connecting { background: var(--text3); animation: none; }
|
|
580
582
|
.live-dot.pulse { animation: pulse 2s infinite; }
|
|
581
583
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
582
584
|
|
|
@@ -648,9 +650,13 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
648
650
|
.tab-btn:hover { background: var(--bg-muted); color: var(--text); }
|
|
649
651
|
.tab-btn.active { background: var(--bg-strong); color: var(--text); font-weight: 500; }
|
|
650
652
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
653
|
+
/* A stray `}` stood here, left behind when `.leash-chip` was removed (34657cf5
|
|
654
|
+
added it, a later cleanup took the rule and not its brace) along with the
|
|
655
|
+
comment that introduced it. At the top level a `}` opens a qualified rule, so
|
|
656
|
+
it swallowed everything up to the next `{` — brace, dead comment and the
|
|
657
|
+
`.icon-btn` selector became one invalid prelude, and `.icon-btn`'s box went
|
|
658
|
+
with it. The icon buttons had been rendering at the browser's default size,
|
|
659
|
+
beside a `:hover` rule that still worked. */
|
|
654
660
|
.icon-btn {
|
|
655
661
|
background: transparent; border: 1px solid transparent;
|
|
656
662
|
width: 30px; height: 30px;
|
|
@@ -1433,6 +1439,26 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1433
1439
|
/* Same shape as `.muted`: the `--mono` token exists and is used 115×, the
|
|
1434
1440
|
CLASS that applies it did not. */
|
|
1435
1441
|
.mono { font-family: var(--mono); }
|
|
1442
|
+
/* The board has two text inputs styled in place — `#agent-panel input` and
|
|
1443
|
+
`.fleet-search input[type="search"]` — and a third, the Sessions search box,
|
|
1444
|
+
that asked for the house look by name and got the user agent's. Same values
|
|
1445
|
+
as those two, said once, so the name now means what it says. */
|
|
1446
|
+
.input {
|
|
1447
|
+
background: var(--bg-muted); border: 1px solid var(--border);
|
|
1448
|
+
color: var(--text); font-family: var(--mono); font-size: var(--fs-body);
|
|
1449
|
+
padding: 8px 12px; border-radius: 0;
|
|
1450
|
+
}
|
|
1451
|
+
.input:focus { outline: 2px solid var(--focus-ring); outline-offset: 1px; border-color: transparent; }
|
|
1452
|
+
/* Visually hidden, still read aloud. The one use — the verdict modal's
|
|
1453
|
+
`aria-labelledby` target — carried the whole pattern inline, which is how a
|
|
1454
|
+
second use would have got a heading that is merely invisible to everyone, or
|
|
1455
|
+
merely visible to everyone. `clip-path` over the old `left:-10000px`: an
|
|
1456
|
+
off-screen box still counts as layout, and in an RTL context widens the page. */
|
|
1457
|
+
.sr-only {
|
|
1458
|
+
position: absolute; width: 1px; height: 1px;
|
|
1459
|
+
margin: -1px; padding: 0; border: 0;
|
|
1460
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
|
|
1461
|
+
}
|
|
1436
1462
|
.tier-badge { font-size: var(--fs-caption); padding: 1px 8px; border-radius: 999px; margin-left: 6px;
|
|
1437
1463
|
font-family: var(--mono); white-space: nowrap; }
|
|
1438
1464
|
.tier-badge:empty { display: none; }
|
|
@@ -2115,11 +2141,30 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2115
2141
|
background: var(--bg-card); color: var(--text); min-width: 26ch;
|
|
2116
2142
|
}
|
|
2117
2143
|
.budgets-table { width: 100%; border-collapse: collapse; font-size: var(--fs-body); }
|
|
2118
|
-
.
|
|
2144
|
+
/* Restored from ad779c7a~1. That commit — about subagent batching — replaced
|
|
2145
|
+
this selector with a bare `.` and left `id var(--border);`, the tail of a
|
|
2146
|
+
duplicated border-bottom, before the closing brace.
|
|
2147
|
+
|
|
2148
|
+
`.` is not a selector, and CSS does not stop there: the prelude runs on to the
|
|
2149
|
+
next `{`, which is `td`'s, and takes `td`'s block with it. Confirmed against
|
|
2150
|
+
the released 3.26.0 with the browser's own parser — of this region it keeps
|
|
2151
|
+
`.budgets-table`, `.bt-slug`, `.bt-desc`, `.bt-runs`, `.bt-cap`, `.bt-spend`,
|
|
2152
|
+
`.bt-usd`, `th[title]` and `.budgets-idle summary`, and exactly `th` and `td`
|
|
2153
|
+
are gone. So the table has had no header typography AND no cell padding or
|
|
2154
|
+
borders since that commit. `th[title] { cursor: help }` survived, one rule
|
|
2155
|
+
further down, which is part of why nobody looked: the headers still did
|
|
2156
|
+
something on hover.
|
|
2157
|
+
|
|
2158
|
+
`font-weight: 500` is kept deliberately and is NOT a line that crept in. It
|
|
2159
|
+
was ad779c7a's own edit — one of seven in that commit, and its sibling
|
|
2160
|
+
`.cost-table th` took the identical change on the line above and parses fine.
|
|
2161
|
+
The weight normalisation was the point of the commit; only this rule's
|
|
2162
|
+
selector was destroyed while applying it. Dropping it would leave `th` at the
|
|
2163
|
+
UA's 700, the one weight this design says it does not have. */
|
|
2164
|
+
.budgets-table th {
|
|
2119
2165
|
text-align: left; font-family: var(--mono); font-weight: 500; font-size: var(--fs-eyebrow);
|
|
2120
2166
|
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2);
|
|
2121
2167
|
padding: 0 12px 8px 0; border-bottom: 1px solid var(--border);
|
|
2122
|
-
id var(--border);
|
|
2123
2168
|
}
|
|
2124
2169
|
.budgets-table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
2125
2170
|
/* A row has to answer three questions: what is this agent, what does it cost,
|
|
@@ -3088,8 +3133,12 @@ id var(--border);
|
|
|
3088
3133
|
</div>
|
|
3089
3134
|
</nav>
|
|
3090
3135
|
<div class="sidebar-footer">
|
|
3091
|
-
|
|
3092
|
-
|
|
3136
|
+
<!-- Ships in the CONNECTING state. This markup used to say "live · synced
|
|
3137
|
+
just now" before any connection existed, so from first paint until the
|
|
3138
|
+
SSE handshake resolved — or forever, if neither handler ever fired —
|
|
3139
|
+
the footer asserted a sync that had not happened. -->
|
|
3140
|
+
<span class="live-dot connecting" id="live-dot"></span>
|
|
3141
|
+
<span id="live-label">connecting…</span>
|
|
3093
3142
|
</div>
|
|
3094
3143
|
</aside>
|
|
3095
3144
|
|
|
@@ -4554,7 +4603,7 @@ async function loadJudgeStatus() {
|
|
|
4554
4603
|
<span class="js-where">${esc(s.fingerprint || '')} · from ${esc(String(s.from || '').replace(/^.*\/(?=\.great_cto)/, '~/'))}</span>
|
|
4555
4604
|
<span class="js-form">
|
|
4556
4605
|
<input id="judge-key" type="password" autocomplete="off" spellcheck="false" placeholder="replace key (sk-or-…)">
|
|
4557
|
-
<button class="btn" onclick="saveJudgeKey()">Replace</button>
|
|
4606
|
+
<button class="btn-black" onclick="saveJudgeKey()">Replace</button>
|
|
4558
4607
|
</span>`
|
|
4559
4608
|
: `<span class="js-dot"></span>
|
|
4560
4609
|
<span><strong>No judge connected.</strong> Every stage will be scored
|
|
@@ -4563,7 +4612,7 @@ async function loadJudgeStatus() {
|
|
|
4563
4612
|
? `<span class="js-where">${esc(s.problems.join('; '))}</span>` : ''}
|
|
4564
4613
|
<span class="js-form">
|
|
4565
4614
|
<input id="judge-key" type="password" autocomplete="off" spellcheck="false" placeholder="OpenRouter key (sk-or-…)">
|
|
4566
|
-
<button class="btn" onclick="saveJudgeKey()">Connect</button>
|
|
4615
|
+
<button class="btn-black" onclick="saveJudgeKey()">Connect</button>
|
|
4567
4616
|
</span>`;
|
|
4568
4617
|
}
|
|
4569
4618
|
|
|
@@ -4685,7 +4734,7 @@ async function loadBudgetsPage() {
|
|
|
4685
4734
|
<div class="bf-why">${esc(String(err && err.message || err))}</div>
|
|
4686
4735
|
<div class="bf-note">Caps already set are unaffected — this failed to
|
|
4687
4736
|
<em>read</em> them, and the pipeline reads them itself, not from here.</div>
|
|
4688
|
-
<button class="btn" onclick="loadBudgetsPage()">Try again</button>
|
|
4737
|
+
<button class="btn-black" onclick="loadBudgetsPage()">Try again</button>
|
|
4689
4738
|
</div>`;
|
|
4690
4739
|
return;
|
|
4691
4740
|
}
|
|
@@ -5070,7 +5119,7 @@ function showVerdictDetail(idx) {
|
|
|
5070
5119
|
modal.innerHTML = `
|
|
5071
5120
|
<div class="rdm-backdrop" onclick="document.getElementById('resume-detail-modal').remove()"></div>
|
|
5072
5121
|
<div class="rdm-card" role="dialog" aria-modal="true" aria-labelledby="rdm-title-verdict">
|
|
5073
|
-
<h2 id="rdm-title-verdict" class="sr-only"
|
|
5122
|
+
<h2 id="rdm-title-verdict" class="sr-only">Verdict detail: ${esc(v.agent || 'agent')} ${esc(v.verdict || '')}</h2>
|
|
5074
5123
|
<div class="rdm-head">
|
|
5075
5124
|
<span class="ri-tag ${cls}">${esc(v.agent || 'agent')}</span>
|
|
5076
5125
|
<span class="rdm-verdict ${cls}">${esc(v.verdict || '')}</span>
|
|
@@ -5971,8 +6020,8 @@ async function loadSessions() {
|
|
|
5971
6020
|
<span class="muted" style="white-space:nowrap">${ago(s.modified)} · ${fmtMB(s.size_bytes)}</span>
|
|
5972
6021
|
</div>
|
|
5973
6022
|
<div style="margin-top:6px">
|
|
5974
|
-
<button class="btn-sm" onclick="openSession('${esc(s.id)}')">transcript</button>
|
|
5975
|
-
<button class="btn-sm" onclick="openEdits('${esc(s.id)}')">edits</button>
|
|
6023
|
+
<button class="btn-ghost-sm" onclick="openSession('${esc(s.id)}')">transcript</button>
|
|
6024
|
+
<button class="btn-ghost-sm" onclick="openEdits('${esc(s.id)}')">edits</button>
|
|
5976
6025
|
</div>
|
|
5977
6026
|
<div id="sess-${esc(s.id)}" style="margin-top:8px"></div>
|
|
5978
6027
|
</div>`).join('');
|
|
@@ -6024,12 +6073,12 @@ async function searchTranscripts() {
|
|
|
6024
6073
|
if (!d) { body.innerHTML = '<div class="muted">Search failed.</div>'; return; }
|
|
6025
6074
|
if (!d.hits?.length) {
|
|
6026
6075
|
body.innerHTML = `<div class="muted">No match for “${esc(q)}”.${d.note ? ' ' + esc(d.note) : ''}
|
|
6027
|
-
<button class="btn-sm" onclick="loadSessions()">back</button></div>`;
|
|
6076
|
+
<button class="btn-ghost-sm" onclick="loadSessions()">back</button></div>`;
|
|
6028
6077
|
return;
|
|
6029
6078
|
}
|
|
6030
6079
|
body.innerHTML =
|
|
6031
6080
|
`<div class="muted" style="margin-bottom:8px">${d.hits.length} match(es)${d.truncated ? ', capped' : ''}
|
|
6032
|
-
<button class="btn-sm" onclick="loadSessions()">back</button></div>` +
|
|
6081
|
+
<button class="btn-ghost-sm" onclick="loadSessions()">back</button></div>` +
|
|
6033
6082
|
d.hits.map(h => `
|
|
6034
6083
|
<div class="card" style="margin-bottom:6px">
|
|
6035
6084
|
<div class="muted" style="font-size:.8em">${esc(h.title || h.session)} · ${esc(h.role)} · ${h.ts ? ago(h.ts) : ''}</div>
|
|
@@ -7163,8 +7212,25 @@ function renderDashboard(m) {
|
|
|
7163
7212
|
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
7164
7213
|
// honest gap, not a zero.
|
|
7165
7214
|
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
|
|
7166
|
-
|
|
7167
|
-
|
|
7215
|
+
// `?? 0` here painted a GREEN ZERO over a question nobody asked. The tile one
|
|
7216
|
+
// line above goes out of its way to avoid exactly this, and these two did not:
|
|
7217
|
+
// a project whose acceptance data is missing showed "0 rework rounds" in
|
|
7218
|
+
// green, and — worse — a project where security has NEVER BEEN SCANNED showed
|
|
7219
|
+
// "0 open security blocks" in green, which is a passing security scan that
|
|
7220
|
+
// did not happen. That is this board's governing defect rendered in its most
|
|
7221
|
+
// reassuring colour.
|
|
7222
|
+
{
|
|
7223
|
+
v: acc.rework_rounds != null ? acc.rework_rounds : absent('uncomputable', 'no accepted work in this window, so rework cannot be counted'),
|
|
7224
|
+
sub: '', label: 'Rework rounds',
|
|
7225
|
+
cls: acc.rework_rounds == null ? '' : acc.rework_rounds ? 'amber' : 'green',
|
|
7226
|
+
},
|
|
7227
|
+
{
|
|
7228
|
+
v: m.security?.blocked != null ? m.security.blocked : absent('uncomputable', 'no security scan has run — this is not a clean result'),
|
|
7229
|
+
sub: '', label: 'Open security blocks',
|
|
7230
|
+
// No colour when unmeasured. Green would say "safe"; red would say
|
|
7231
|
+
// "unsafe"; neither is known.
|
|
7232
|
+
cls: m.security?.blocked == null ? '' : m.security.blocked ? 'red' : 'green',
|
|
7233
|
+
},
|
|
7168
7234
|
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
7169
7235
|
];
|
|
7170
7236
|
document.getElementById('mp-secondary').innerHTML = secondary.map(s => `
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-posture — what does this agent's tool grant actually LET IT DO?
|
|
3
|
+
*
|
|
4
|
+
* ADR-009 ends with an instruction nobody had a way to follow: "Ask at design
|
|
5
|
+
* time, when the capability is added — not after the incident." The question it
|
|
6
|
+
* asks is about consequence — is this expensive to undo? — while every agent
|
|
7
|
+
* file answers a different question, in a different language: which tools are on
|
|
8
|
+
* the `tools:` line. Reviewing the second does not answer the first. `Bash` and
|
|
9
|
+
* `Bash(node:*)` sit two characters apart and read as careful and careless; in
|
|
10
|
+
* consequence they are the same grant.
|
|
11
|
+
*
|
|
12
|
+
* So this names the grant in the language of the decision. Vocabulary shape
|
|
13
|
+
* borrowed from OpenFirma's capability postures (`credential.read`,
|
|
14
|
+
* `communication.external.send`, `code.destructive`) — the idea only; that
|
|
15
|
+
* project is GPL-3.0 and this one is MIT, so nothing was copied.
|
|
16
|
+
*
|
|
17
|
+
* NOTHING here decides anything, exactly as in `gate-reversibility.mjs`, whose
|
|
18
|
+
* ADR-009 categories it reuses rather than inventing a second vocabulary for the
|
|
19
|
+
* same axis. It classifies, so a reviewer can see the grant they are approving.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CATEGORIES } from './gate-reversibility.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The postures. Each cites the ADR-009 category that makes it expensive, or
|
|
26
|
+
* `null` when the repair is simply to do the thing again.
|
|
27
|
+
*/
|
|
28
|
+
export const POSTURES = Object.freeze({
|
|
29
|
+
'code.read': {
|
|
30
|
+
category: null,
|
|
31
|
+
means: 'read files in the working tree',
|
|
32
|
+
},
|
|
33
|
+
'code.write': {
|
|
34
|
+
category: null,
|
|
35
|
+
means: 'create or modify files — the repair is another edit',
|
|
36
|
+
},
|
|
37
|
+
'code.destructive': {
|
|
38
|
+
category: 'destroys-evidence',
|
|
39
|
+
means: 'delete files, rewrite history, or overwrite work that is not in the index',
|
|
40
|
+
},
|
|
41
|
+
'credential.read': {
|
|
42
|
+
category: 'unrevocable-disclosure',
|
|
43
|
+
means: 'reach secrets on disk or in the environment',
|
|
44
|
+
},
|
|
45
|
+
'communication.external.send': {
|
|
46
|
+
category: 'escapes-the-machine',
|
|
47
|
+
means: 'send data off this machine — a push, a publish, a request body, a URL',
|
|
48
|
+
},
|
|
49
|
+
'network.fetch': {
|
|
50
|
+
category: null,
|
|
51
|
+
means: 'pull from the network; nothing of the user\'s leaves except the request',
|
|
52
|
+
},
|
|
53
|
+
'process.spawn': {
|
|
54
|
+
category: null,
|
|
55
|
+
means: 'start a process or another agent, which then holds its own grant',
|
|
56
|
+
},
|
|
57
|
+
payments: {
|
|
58
|
+
category: 'costs-money',
|
|
59
|
+
means: 'spend money — paid API capacity or provisioned infrastructure',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A shell is a shell. Every posture an unrestricted `Bash` confers, in one list,
|
|
65
|
+
* so the interpreters below cannot drift away from it.
|
|
66
|
+
*/
|
|
67
|
+
const FULL_SHELL = Object.freeze([
|
|
68
|
+
'code.read', 'code.write', 'code.destructive',
|
|
69
|
+
'credential.read', 'communication.external.send', 'network.fetch', 'process.spawn',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bash sub-grants, by the command they scope to.
|
|
74
|
+
*
|
|
75
|
+
* `full: true` marks the ones that scope to a name but not to a capability — an
|
|
76
|
+
* interpreter, or a command that runs other commands. `Bash(node:*)` is
|
|
77
|
+
* `node -e '<anything>'`; `Bash(find:*)` is `find . -exec <anything>`;
|
|
78
|
+
* `Bash(xargs:*)` and `Bash(awk:*)` likewise. These read as restrictions and are
|
|
79
|
+
* not, which is the reason this file exists.
|
|
80
|
+
*/
|
|
81
|
+
const BASH_SCOPES = Object.freeze({
|
|
82
|
+
// Scoped in name only — a full shell wearing a command name.
|
|
83
|
+
node: { full: true, why: 'node -e runs arbitrary JavaScript, including child_process' },
|
|
84
|
+
python3: { full: true, why: 'python3 -c runs arbitrary Python, including os.system' },
|
|
85
|
+
python: { full: true, why: 'python -c runs arbitrary Python, including os.system' },
|
|
86
|
+
xargs: { full: true, why: 'xargs exists to run other commands' },
|
|
87
|
+
find: { full: true, why: 'find -exec and -delete run other commands and remove files' },
|
|
88
|
+
awk: { full: true, why: 'awk has system() and can redirect print into a file' },
|
|
89
|
+
sh: { full: true, why: 'a shell' },
|
|
90
|
+
bash: { full: true, why: 'a shell' },
|
|
91
|
+
zsh: { full: true, why: 'a shell' },
|
|
92
|
+
env: { full: true, why: 'env runs the command that follows it' },
|
|
93
|
+
eval: { full: true, why: 'eval runs the string that follows it' },
|
|
94
|
+
|
|
95
|
+
// Genuinely narrower.
|
|
96
|
+
git: { postures: ['code.read', 'code.write', 'code.destructive', 'communication.external.send'],
|
|
97
|
+
why: 'push sends the tree to a remote; checkout -- and reset --hard destroy uncommitted work' },
|
|
98
|
+
npm: { postures: ['code.read', 'code.write', 'network.fetch', 'communication.external.send', 'process.spawn'],
|
|
99
|
+
why: 'install runs lifecycle scripts; publish escapes the machine' },
|
|
100
|
+
bd: { postures: ['code.read', 'code.write', 'communication.external.send'],
|
|
101
|
+
why: 'bd sync pushes the task store to its remote' },
|
|
102
|
+
cat: { postures: ['code.read', 'credential.read'],
|
|
103
|
+
why: 'the file it reads may be ~/.great_cto/secrets.env' },
|
|
104
|
+
source: { postures: ['code.read', 'credential.read'],
|
|
105
|
+
why: 'sourcing an env file puts its secrets in the environment' },
|
|
106
|
+
sort: { postures: ['code.read', 'code.write'], why: 'sort -o writes' },
|
|
107
|
+
tee: { postures: ['code.read', 'code.write'], why: 'writes what it reads' },
|
|
108
|
+
rm: { postures: ['code.destructive'], why: 'removes files' },
|
|
109
|
+
|
|
110
|
+
ls: { postures: ['code.read'] },
|
|
111
|
+
grep: { postures: ['code.read'] },
|
|
112
|
+
wc: { postures: ['code.read'] },
|
|
113
|
+
head: { postures: ['code.read'] },
|
|
114
|
+
tail: { postures: ['code.read'] },
|
|
115
|
+
date: { postures: ['code.read'] },
|
|
116
|
+
echo: { postures: ['code.read'] },
|
|
117
|
+
printf: { postures: ['code.read'] },
|
|
118
|
+
export: { postures: ['code.read'] },
|
|
119
|
+
mkdir: { postures: ['code.write'] },
|
|
120
|
+
touch: { postures: ['code.write'] },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Non-Bash tools. */
|
|
124
|
+
const TOOL_POSTURES = Object.freeze({
|
|
125
|
+
Read: ['code.read'],
|
|
126
|
+
Glob: ['code.read'],
|
|
127
|
+
Grep: ['code.read'],
|
|
128
|
+
Write: ['code.write'],
|
|
129
|
+
Edit: ['code.write'],
|
|
130
|
+
NotebookEdit: ['code.write'],
|
|
131
|
+
// A URL is a channel. A fetch of `https://evil/?leak=<secret>` is a send, and
|
|
132
|
+
// an agent that can read a file and reach the network can move it.
|
|
133
|
+
WebFetch: ['network.fetch', 'communication.external.send'],
|
|
134
|
+
WebSearch: ['network.fetch', 'communication.external.send'],
|
|
135
|
+
Agent: ['process.spawn'],
|
|
136
|
+
Task: ['process.spawn'],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** MCP and beta tools, matched by prefix — the list of these grows monthly. */
|
|
140
|
+
const PREFIX_POSTURES = Object.freeze([
|
|
141
|
+
[/^advisor_/, ['network.fetch', 'communication.external.send', 'payments']],
|
|
142
|
+
[/^memory_/, ['code.read', 'code.write']],
|
|
143
|
+
[/^mcp__great_cto_llm_router__/, ['network.fetch', 'communication.external.send', 'payments']],
|
|
144
|
+
[/^mcp__grafana__/, ['network.fetch']],
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Split a `tools:` frontmatter value into tokens. `Bash(git:*)` contains a comma
|
|
149
|
+
* in no case we ship, but the split is on commas outside parentheses anyway, so
|
|
150
|
+
* a future `Bash(a:*, b:*)` does not silently become two broken tokens.
|
|
151
|
+
*/
|
|
152
|
+
export function splitTools(line) {
|
|
153
|
+
const out = [];
|
|
154
|
+
let depth = 0; let cur = '';
|
|
155
|
+
for (const ch of String(line ?? '')) {
|
|
156
|
+
if (ch === '(') depth++;
|
|
157
|
+
if (ch === ')') depth--;
|
|
158
|
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
|
159
|
+
cur += ch;
|
|
160
|
+
}
|
|
161
|
+
if (cur.trim()) out.push(cur.trim());
|
|
162
|
+
return out.filter(Boolean);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @returns {{postures:string[], unknown:boolean, fullShell:boolean, why:string}}
|
|
167
|
+
*
|
|
168
|
+
* THREE states for the tool itself, and `unknown` is the one that earns its
|
|
169
|
+
* keep: a tool this table has never heard of grants `unknown`, never nothing.
|
|
170
|
+
* A grant nobody classified must not read as a grant that was classified and
|
|
171
|
+
* found harmless.
|
|
172
|
+
*/
|
|
173
|
+
export function postureOfTool(tool) {
|
|
174
|
+
const t = String(tool ?? '').trim();
|
|
175
|
+
if (!t) return { postures: [], unknown: true, fullShell: false, why: 'no tool given' };
|
|
176
|
+
|
|
177
|
+
if (t === '*' || t === 'All tools') {
|
|
178
|
+
return { postures: [...FULL_SHELL, 'payments'], unknown: false, fullShell: true, why: 'every tool' };
|
|
179
|
+
}
|
|
180
|
+
if (t === 'Bash') {
|
|
181
|
+
return { postures: [...FULL_SHELL], unknown: false, fullShell: true, why: 'unrestricted shell' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const scoped = /^Bash\(([^:)]+)/.exec(t);
|
|
185
|
+
if (scoped) {
|
|
186
|
+
const cmd = scoped[1].trim();
|
|
187
|
+
const hit = BASH_SCOPES[cmd];
|
|
188
|
+
if (!hit) {
|
|
189
|
+
return {
|
|
190
|
+
postures: [], unknown: true, fullShell: false,
|
|
191
|
+
why: `Bash scope '${cmd}' is not in the table — treat as unjudged, not as narrow. Add it to agent-posture.mjs.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (hit.full) {
|
|
195
|
+
return { postures: [...FULL_SHELL], unknown: false, fullShell: true, why: hit.why };
|
|
196
|
+
}
|
|
197
|
+
return { postures: [...hit.postures], unknown: false, fullShell: false, why: hit.why ?? '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (TOOL_POSTURES[t]) {
|
|
201
|
+
return { postures: [...TOOL_POSTURES[t]], unknown: false, fullShell: false, why: '' };
|
|
202
|
+
}
|
|
203
|
+
for (const [re, postures] of PREFIX_POSTURES) {
|
|
204
|
+
if (re.test(t)) return { postures: [...postures], unknown: false, fullShell: false, why: '' };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
postures: [], unknown: true, fullShell: false,
|
|
208
|
+
why: `'${t}' is not in the table — treat as unjudged, not as harmless. Add it to agent-posture.mjs.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The posture of a whole `tools:` line.
|
|
214
|
+
*
|
|
215
|
+
* @returns {{postures:string[], expensive:string[], unknownTools:string[],
|
|
216
|
+
* fullShellVia:string[], scopedInNameOnly:string[]}}
|
|
217
|
+
*/
|
|
218
|
+
export function postureOf(toolsLine) {
|
|
219
|
+
const tools = splitTools(toolsLine);
|
|
220
|
+
const postures = new Set();
|
|
221
|
+
const unknownTools = [];
|
|
222
|
+
const fullShellVia = [];
|
|
223
|
+
const scopedInNameOnly = [];
|
|
224
|
+
|
|
225
|
+
for (const t of tools) {
|
|
226
|
+
const r = postureOfTool(t);
|
|
227
|
+
if (r.unknown) { unknownTools.push(t); continue; }
|
|
228
|
+
for (const p of r.postures) postures.add(p);
|
|
229
|
+
if (r.fullShell) {
|
|
230
|
+
fullShellVia.push(t);
|
|
231
|
+
// `Bash` is honest about being a shell. `Bash(node:*)` is not.
|
|
232
|
+
if (t !== 'Bash' && t !== '*' && t !== 'All tools') scopedInNameOnly.push(t);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const ordered = Object.keys(POSTURES).filter((p) => postures.has(p));
|
|
237
|
+
return {
|
|
238
|
+
postures: ordered,
|
|
239
|
+
expensive: ordered.filter((p) => POSTURES[p].category),
|
|
240
|
+
unknownTools,
|
|
241
|
+
fullShellVia,
|
|
242
|
+
scopedInNameOnly,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One line for a human reviewing a grant, in their words. */
|
|
247
|
+
export function describePosture(r) {
|
|
248
|
+
const parts = [];
|
|
249
|
+
if (r.expensive.length) {
|
|
250
|
+
parts.push(`expensive: ${r.expensive.map((p) => `${p} (${CATEGORIES[POSTURES[p].category]})`).join('; ')}`);
|
|
251
|
+
} else if (r.postures.length) {
|
|
252
|
+
parts.push(`routine: ${r.postures.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
if (r.scopedInNameOnly.length) {
|
|
255
|
+
parts.push(`scoped in name only: ${r.scopedInNameOnly.join(', ')} — a full shell`);
|
|
256
|
+
}
|
|
257
|
+
if (r.unknownTools.length) {
|
|
258
|
+
parts.push(`NOT CLASSIFIED: ${r.unknownTools.join(', ')} — unjudged, not harmless`);
|
|
259
|
+
}
|
|
260
|
+
return parts.join(' · ') || 'no tools granted';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Every posture name, for a surface that wants a legend. */
|
|
264
|
+
export function knownPostures() {
|
|
265
|
+
return Object.keys(POSTURES);
|
|
266
|
+
}
|
|
@@ -32,13 +32,38 @@
|
|
|
32
32
|
* authoring work, done deliberately, a few documents at a time.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
35
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
/** Generated summaries and translations are copies, not documents. */
|
|
39
39
|
const IS_SUMMARY = /\.summary\.md$/;
|
|
40
|
-
const IS_TRANSLATION = /^docs\/[a-z]{2}(-[A-Z]{2})?\//;
|
|
41
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A two-letter directory is not evidence of a language.
|
|
43
|
+
*
|
|
44
|
+
* This used to be `/^docs\/[a-z]{2}(-[A-Z]{2})?\//`, which reads `docs/qa/`,
|
|
45
|
+
* `docs/ai/`, `docs/ci/`, `docs/ux/` and `docs/db/` as language codes and drops
|
|
46
|
+
* every document under them — silently, from the board's docs tab and from the
|
|
47
|
+
* link graph both, because a dropped document declares nothing that could go
|
|
48
|
+
* missing. `docs/qa/` was already being eaten; the rest were waiting.
|
|
49
|
+
*
|
|
50
|
+
* A whitelist of language codes would rot the first time someone adds one, so
|
|
51
|
+
* the test is STRUCTURAL instead: a translation MIRRORS a document at the docs
|
|
52
|
+
* root. `docs/ru/index.md` is a translation because `docs/index.md` exists;
|
|
53
|
+
* `docs/qa/QA-judge-provenance.md` is not, because `docs/QA-judge-provenance.md`
|
|
54
|
+
* does not. Measured across all ten two-letter directories here — de, es, fr,
|
|
55
|
+
* ja, ko, pt-BR, ru, zh-CN, zh-TW each mirror 1 of 1; qa mirrors 0 of 2 — and it
|
|
56
|
+
* self-corrects when a language is added or removed.
|
|
57
|
+
*
|
|
58
|
+
* Consequence worth stating, because it is a visible surface change hiding
|
|
59
|
+
* inside a classifier fix: `docs/qa/` drafts now APPEAR on the board's docs tab.
|
|
60
|
+
* That is the intended direction — the board is local and should show drafts —
|
|
61
|
+
* but it is not what a reader expects from a regex change.
|
|
62
|
+
*/
|
|
63
|
+
const LANG_DIR = /^docs\/[a-z]{2}(?:-[A-Z]{2})?\//;
|
|
64
|
+
|
|
65
|
+
/** The basename a translation would be a translation OF. */
|
|
66
|
+
const baseName = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
|
|
42
67
|
export function listDocs(root = 'docs') {
|
|
43
68
|
// The translation rule is written against a path that starts at the docs
|
|
44
69
|
// directory. `root` may be relative ('docs') or absolute (the board serves
|
|
@@ -47,7 +72,7 @@ export function listDocs(root = 'docs') {
|
|
|
47
72
|
// matches nothing on an absolute walk and translations count as documents,
|
|
48
73
|
// which is a wrong number that looks like a right one.
|
|
49
74
|
const base = path.dirname(root);
|
|
50
|
-
const
|
|
75
|
+
const found = [];
|
|
51
76
|
const walk = (dir) => {
|
|
52
77
|
let entries;
|
|
53
78
|
try { entries = readdirSync(dir); } catch { return; }
|
|
@@ -56,11 +81,24 @@ export function listDocs(root = 'docs') {
|
|
|
56
81
|
let st;
|
|
57
82
|
try { st = statSync(full); } catch { continue; }
|
|
58
83
|
if (st.isDirectory()) walk(full);
|
|
59
|
-
else if (e.endsWith('.md') && !IS_SUMMARY.test(e)
|
|
84
|
+
else if (e.endsWith('.md') && !IS_SUMMARY.test(e)) found.push({ full, rel: path.relative(base, full) });
|
|
60
85
|
}
|
|
61
86
|
};
|
|
62
87
|
walk(root);
|
|
63
|
-
|
|
88
|
+
|
|
89
|
+
// Two passes, because whether a file under a two-letter directory is a
|
|
90
|
+
// TRANSLATION depends on the rest of the tree: it is one when a document of
|
|
91
|
+
// the same name lives outside every language directory. One pass cannot know
|
|
92
|
+
// that, and the single-pass version of this rule only looked at the docs
|
|
93
|
+
// root — which called `docs/ru/ADR-001-a.md` a document because its source
|
|
94
|
+
// sits in `docs/adr/`.
|
|
95
|
+
const sources = new Set(
|
|
96
|
+
found.filter((f) => !LANG_DIR.test(f.rel)).map((f) => baseName(f.rel)),
|
|
97
|
+
);
|
|
98
|
+
return found
|
|
99
|
+
.filter((f) => !(LANG_DIR.test(f.rel) && sources.has(baseName(f.rel))))
|
|
100
|
+
.map((f) => f.full)
|
|
101
|
+
.sort();
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
/**
|
package/package.json
CHANGED