great-cto 3.26.2 → 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.2",
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
 
@@ -3131,8 +3133,12 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3131
3133
  </div>
3132
3134
  </nav>
3133
3135
  <div class="sidebar-footer">
3134
- <span class="live-dot pulse" id="live-dot"></span>
3135
- <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>
3136
3142
  </div>
3137
3143
  </aside>
3138
3144
 
@@ -7206,8 +7212,25 @@ function renderDashboard(m) {
7206
7212
  // how often work bounced. '—' when nothing was accepted in the window — an
7207
7213
  // honest gap, not a zero.
7208
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' },
7209
- { v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
7210
- { 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
+ },
7211
7234
  { v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
7212
7235
  ];
7213
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.26.2",
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",