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.
@@ -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.1",
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?.[1]?.trim() || 'sonnet',
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
- const stuck = tasks
1522
- .filter(t => t.status === 'in_progress')
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 startedAt = t.startedAt ? new Date(t.startedAt).getTime() : null;
1525
- const ageH = startedAt ? (nowMs - startedAt) / 3600000 : null;
1526
- return { id: t.id, title: t.title, agent: t.agent, age_h: ageH ? Math.round(ageH) : null };
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.age_h !== null && t.age_h > STUCK_H);
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, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
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
- /* Period chip-bar slim version of tab-btn */
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
- <span class="live-dot pulse" id="live-dot"></span>
3092
- <span id="live-label">live · synced just now</span>
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" style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;">Verdict detail: ${esc(v.agent || 'agent')} ${esc(v.verdict || '')}</h2>
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
- { v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
7167
- { v: m.security?.blocked ?? 0, sub: '', label: 'Open security blocks', cls: m.security?.blocked ? 'red' : 'green' },
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 out = [];
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) && !IS_TRANSLATION.test(path.relative(base, full))) out.push(full);
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
- return out.sort();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.26.1",
3
+ "version": "3.26.4",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",