great-cto 3.59.1 → 3.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2426,6 +2426,13 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2426
2426
  .usage-table th { text-align: left; font-weight: 500; color: var(--text3); font-size: var(--fs-caption); padding: 4px 6px; border-bottom: 1px solid var(--border); white-space: nowrap; }
2427
2427
  .usage-table td { padding: 6px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; }
2428
2428
  .usage-table td.num, .usage-table th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
2429
+ .skills-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 16px; }
2430
+ .skills-controls label { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-small); color: var(--text2); }
2431
+ .skills-controls input, .skills-controls select { min-height: 32px; max-width: 100%; padding: 5px 8px; border: 1px solid var(--border); color: var(--text); background: var(--bg-muted); font: inherit; }
2432
+ .skills-controls :is(input, select):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
2433
+ #skills-table td { max-width: 320px; overflow-wrap: anywhere; }
2434
+ #skills-table code { word-break: break-all; font-family: var(--mono); font-size: var(--fs-caption); }
2435
+ @media (max-width: 767px) { .skills-controls :is(input, select) { min-height: 44px; } }
2429
2436
  .usage-title { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2430
2437
  .usage-tag { display: inline-block; font-family: var(--mono); font-size: var(--fs-eyebrow); letter-spacing: 0.04em; padding: 1px 5px; margin-left: 6px; border: 1px solid var(--border-strong); color: var(--text2); }
2431
2438
  .usage-share { display: inline-block; width: 48px; height: 6px; background: var(--bg-muted); margin-right: 6px; vertical-align: 1px; }
@@ -3461,6 +3468,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3461
3468
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 20V10 M10 20V4 M16 20v-7 M22 20H2"/></svg>
3462
3469
  <span class="nav-label">Usage</span>
3463
3470
  </div>
3471
+ <div class="nav-item" data-tab="skills" role="tab" tabindex="0" aria-label="Skills" title="Skills" onkeydown="navTabKeydown(event, this)" onclick="switchTab('skills', this)">
3472
+ <svg class="icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 3h6a2 2 0 0 1 2 2v16a3 3 0 0 0-3-3H4V3zM20 3h-6a2 2 0 0 0-2 2v16a3 3 0 0 1 3-3h5V3z"/></svg>
3473
+ <span class="nav-label">Skills</span>
3474
+ </div>
3464
3475
  </nav>
3465
3476
  </details>
3466
3477
  <div class="nav-divider"></div>
@@ -3711,14 +3722,32 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3711
3722
  <div class="panel" id="panel-usage" role="tabpanel">
3712
3723
  <div class="metrics-page">
3713
3724
  <div class="usage-head">
3714
- <h1 class="page-title">Usage <span class="muted" style="font-weight:400;font-size:var(--fs-small)">what Claude Code and Codex consumed on this machine</span></h1>
3725
+ <h1 class="page-title">Usage <span class="muted" style="font-weight:400;font-size:var(--fs-small)">what Claude Code and Codex consumed in the selected project</span></h1>
3715
3726
  <div class="usage-chips" role="group" aria-label="Period">
3716
3727
  <button type="button" class="usage-chip" data-days="7" onclick="loadUsage(7)">7D</button>
3717
3728
  <button type="button" class="usage-chip active" data-days="30" onclick="loadUsage(30)">30D</button>
3718
3729
  <button type="button" class="usage-chip" data-days="90" onclick="loadUsage(90)">90D</button>
3719
3730
  </div>
3720
3731
  </div>
3721
- <div id="usage-body" class="usage-note" role="status">Reading the session logs of Claude Code and Codex on this machine.</div>
3732
+ <div id="usage-body" class="usage-note" role="status">Reading this project's Claude Code and Codex session logs.</div>
3733
+ </div>
3734
+ </div>
3735
+
3736
+ <div class="panel" id="panel-skills" role="tabpanel" aria-label="Skills">
3737
+ <div class="metrics-page">
3738
+ <div class="usage-head">
3739
+ <h1 class="page-title">Skills <span class="muted" style="font-weight:400;font-size:var(--fs-small)">local inventory, not a quality ranking</span></h1>
3740
+ <button type="button" class="usage-chip" onclick="loadSkillsInventory()">Reload</button>
3741
+ </div>
3742
+ <p class="usage-note">Project scope includes only the selected project's skill directories. Machine scope shows local host directories and plugin caches, not other projects' statistics.</p>
3743
+ <div class="skills-controls">
3744
+ <label>Search <input id="skills-search" type="search" aria-label="Filter skills" placeholder="Filter skills…" oninput="renderSkillsInventory()"></label>
3745
+ <label>Host location <select id="skills-host" aria-label="Skill host location" onchange="renderSkillsInventory()"><option value="all">All locations</option><option>Claude Code</option><option>Codex</option><option>Shared</option><option>Registry</option></select></label>
3746
+ <label>Scope <select id="skills-scope" aria-label="Skill scope" onchange="renderSkillsInventory()"><option value="all">Machine + selected project</option><option value="project">Selected project only</option><option value="machine">Machine only</option></select></label>
3747
+ </div>
3748
+ <p id="skills-status" class="usage-note" role="status">Reading local skill metadata.</p>
3749
+ <div id="skills-body" aria-busy="false"></div>
3750
+ <p class="usage-note">A file in a host location is not proof that the host enabled or loaded it. Shared and registry locations do not prove compatibility with either host. SHA-256 covers only the document, not its scripts or reference tree. Upstream trust and task quality are not measured. No installation, execution or upstream network checks. Read snapshots are cached for up to 30 seconds.</p>
3722
3751
  </div>
3723
3752
  </div>
3724
3753
 
@@ -4107,6 +4136,7 @@ window.GctoWork.bind({
4107
4136
  let searchQuery = '';
4108
4137
  let allProjects = [];
4109
4138
  let currentProject = '';
4139
+ let serverDefaultProject = false;
4110
4140
  let inboxData = null;
4111
4141
  let activeFilters = { agents: new Set(), priorities: new Set(), labels: new Set() };
4112
4142
  // Period selector for dashboard (1/7/30/90/365 days). Persists in localStorage.
@@ -4114,12 +4144,12 @@ let currentPeriod = parseInt(localStorage.getItem('gcto.period') || '30', 10) ||
4114
4144
 
4115
4145
  // Returns ?project=slug query string for all API calls after init() sets currentProject
4116
4146
  function pqs() {
4117
- return currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
4147
+ return currentProject && !serverDefaultProject ? `?project=${encodeURIComponent(currentProject)}` : '';
4118
4148
  }
4119
4149
  // Combined query string: ?project=X&days=Y (for metrics/cost APIs)
4120
4150
  function pqsd() {
4121
4151
  const parts = [];
4122
- if (currentProject) parts.push(`project=${encodeURIComponent(currentProject)}`);
4152
+ if (currentProject && !serverDefaultProject) parts.push(`project=${encodeURIComponent(currentProject)}`);
4123
4153
  parts.push(`days=${currentPeriod}`);
4124
4154
  return '?' + parts.join('&');
4125
4155
  }
@@ -5130,7 +5160,7 @@ async function loadJudgeScores() {
5130
5160
  const el = document.getElementById('judge-scores');
5131
5161
  if (!el) return;
5132
5162
  let d;
5133
- try { d = await api(`/api/scores${pqs()}&limit=40`); } catch { d = null; }
5163
+ try { d = await api(`/api/scores?limit=40${pqs().replace('?', '&')}`); } catch { d = null; }
5134
5164
 
5135
5165
  // Three states, and the empty one earns a sentence rather than a blank space:
5136
5166
  // "nothing has been verified yet" and "the panel is broken" look identical
@@ -5885,7 +5915,7 @@ function renderTurns(snap) {
5885
5915
  if (snap.state === 'unreadable') return `<div class="aa-empty aa-bad">Turns not measured: ${esc(snap.why || 'git refused')}</div>`;
5886
5916
  const sessions = Array.isArray(snap.sessions) ? snap.sessions : [];
5887
5917
  if (!sessions.length) return `<div class="aa-empty">No turn snapshots yet — they start with the next agent turn.</div>`;
5888
- const when = (ms) => { try { return ms ? new Date(ms).toLocaleString([], { hour12: false }) : ''; } catch { return ''; } };
5918
+ const when = (ms) => { try { return ms ? new Date(ms).toLocaleString('en-US', { hour12: false }) : ''; } catch { return ''; } };
5889
5919
  return sessions.map((s) => {
5890
5920
  const count = Math.min(Number(s.turns) || 0, 50);
5891
5921
  const first = Math.max(0, (Number(s.newestTurn) || 0) - count + 1);
@@ -5974,7 +6004,7 @@ function renderAgentActivity(snap) {
5974
6004
  root.innerHTML = `<div class="aa-empty">No agent events recorded yet — they start with the next agent run.</div>`;
5975
6005
  return;
5976
6006
  }
5977
- const time = (ts) => { try { return new Date(ts).toLocaleTimeString([], { hour12: false }); } catch { return ''; } };
6007
+ const time = (ts) => { try { return new Date(ts).toLocaleTimeString('en-US', { hour12: false }); } catch { return ''; } };
5978
6008
  const what = (e) => {
5979
6009
  if (e.kind === 'tool') {
5980
6010
  const p = Array.isArray(e.paths) && e.paths.length ? ` ${esc(e.paths[0])}${e.paths.length > 1 ? ` +${e.paths.length - 1}` : ''}` : '';
@@ -6000,16 +6030,70 @@ function renderAgentActivity(snap) {
6000
6030
  // The Harnesses card. Four states for the second opinion, and the fourth is
6001
6031
  // the reason the card exists: `unavailable` — declared codex, no codex here —
6002
6032
  // must never look like `none` or like a switch someone turned off.
6033
+ // Read-only Skills: keep controls mounted so filtering never steals focus.
6034
+ let skillsData = null;
6035
+ let _skillsRequest = 0;
6036
+ async function loadSkillsInventory() {
6037
+ const request = ++_skillsRequest, project = currentProject;
6038
+ const status = document.getElementById('skills-status');
6039
+ const body = document.getElementById('skills-body');
6040
+ skillsData = null; body.innerHTML = ''; body.setAttribute('aria-busy', 'true');
6041
+ status.textContent = 'Reading local skill metadata.';
6042
+ try {
6043
+ const data = await api(`/api/skills${pqs()}`, { signal: AbortSignal.timeout(10000) });
6044
+ if (request !== _skillsRequest || project !== currentProject) return;
6045
+ if (!data || !Array.isArray(data.skills)) throw new Error('Invalid inventory response');
6046
+ skillsData = data; renderSkillsInventory();
6047
+ } catch {
6048
+ if (request !== _skillsRequest || project !== currentProject) return;
6049
+ status.textContent = 'Could not read skill inventory. No counts or provenance were measured.';
6050
+ body.innerHTML = '';
6051
+ } finally {
6052
+ if (request === _skillsRequest && project === currentProject) body.setAttribute('aria-busy', 'false');
6053
+ }
6054
+ }
6055
+ function renderSkillsInventory() {
6056
+ if (!skillsData) return;
6057
+ const body = document.getElementById('skills-body');
6058
+ const q = document.getElementById('skills-search').value.trim().toLowerCase();
6059
+ const host = document.getElementById('skills-host').value, scope = document.getElementById('skills-scope').value;
6060
+ const d = skillsData;
6061
+ const rows = d.skills.filter(s => (host === 'all' || s.host === host) && (scope === 'all' || s.scope === scope)
6062
+ && [s.name, s.location, s.declared_source].some(v => String(v || '').toLowerCase().includes(q)));
6063
+ document.getElementById('skills-status').textContent = `${d.state === 'partial' ? 'Partial inventory' : 'Observed inventory'} · ${rows.length} shown · ${d.skills.length} documents listed · ${fmtDate(d.observed_at)}${d.truncated ? ' · scan limit reached' : ''}`;
6064
+ const labels = { 'missing-frontmatter': 'Missing frontmatter', 'name-mismatch': 'Name differs from directory',
6065
+ 'description-length': 'Description outside lint limits', 'invalid-frontmatter': 'Invalid frontmatter',
6066
+ 'ambiguous-yaml': 'Ambiguous YAML', 'over-context-budget': 'Over 20 KB context budget',
6067
+ 'too-large': 'Document too large', 'changed-during-read': 'Document changed during read',
6068
+ unreadable: 'Unreadable', missing: 'Missing', 'outside-root': 'Outside allowed root' };
6069
+ const table = rows.length ? `<div class="usage-table-wrap"><table id="skills-table" class="usage-table"><caption class="usage-note">Local document observations</caption><thead><tr><th scope="col">Skill</th><th scope="col">Location</th><th scope="col">Scope</th><th scope="col">Document integrity</th><th scope="col">Structure</th></tr></thead><tbody>${rows.map(s => `<tr>
6070
+ <td>${esc(s.name)}<div class="usage-sub">${esc(s.host)} location</div></td>
6071
+ <td>${esc(s.location)}${s.declared_source ? `<div class="usage-sub">Registry declaration: ${esc(s.declared_source)}</div>` : ''}${s.cache_version ? `<div class="usage-sub">Cache version: ${esc(s.cache_version)}</div>` : ''}</td>
6072
+ <td>${s.scope === 'project' ? 'Selected project' : 'Machine'}</td>
6073
+ <td>${s.document_sha256 ? `<code>${esc(s.document_sha256)}</code><div class="usage-sub">${esc(s.bytes)} bytes · document only</div>` : esc(labels[s.read_state] || 'Not measured')}<div class="usage-sub">Upstream revision: unknown</div></td>
6074
+ <td>${s.read_state !== 'observed' ? 'Not checked' : s.warnings.length ? s.warnings.map(v => esc(labels[v] || v)).join('<br>') : 'No structural warnings'}<div class="usage-sub">Enabled / loaded: unknown</div></td>
6075
+ </tr>`).join('')}</tbody></table></div>` : `<p class="usage-note">${d.skills.length ? 'No skills match these filters.' : d.state === 'partial' ? 'No documents were observed. Some sources could not be read; this is not a complete empty inventory.' : 'No skill documents found in the scanned local locations.'}</p>`;
6076
+ body.innerHTML = table + `<details class="usage-note" style="margin-top:16px"><summary>Source coverage and registry</summary><p>Registry: ${esc(d.registry.state)} · declared at: ${esc(d.registry.declared_at || 'unknown')} · declared plugin version: ${esc(d.registry.plugin_version || 'unknown')}</p>
6077
+ <ul>${d.sources.map(s => `<li>${esc(s.label)} · ${esc(s.scope === 'project' ? 'selected project' : 'machine')} · ${esc(s.state)}${s.warnings.length ? ' · ' + s.warnings.map(esc).join(', ') : ''}</li>`).join('')}</ul></details>`;
6078
+ }
6079
+
6003
6080
  // ── Usage (great_cto-egkw) ─────────────────────────────────────────────────
6004
6081
  // Both hosts, side by side, from /api/usage. Three honesty rules carried from
6005
6082
  // the reader: an unpriced model is n/a, never $0; Claude Code's plan use is not
6006
6083
  // written anywhere, so it is not drawn — only the refusals are; and a host with
6007
- // no logs on this machine says so instead of showing zeros.
6084
+ // no logs attributed to this project says so instead of showing zeros.
6008
6085
  let usageDays = 30;
6009
6086
  let usageHost = 'claude';
6010
6087
  let usageData = null;
6011
6088
  let _usageTimer = null;
6012
6089
  let _usageRequest = 0;
6090
+ function resetProjectUsage() {
6091
+ clearTimeout(_usageTimer); clearTimeout(_outcomesTimer);
6092
+ ++_usageRequest; ++_outcomesRequest;
6093
+ usageData = null;
6094
+ const body = document.getElementById('usage-body');
6095
+ if (body) { body.className = 'usage-note'; body.textContent = 'Reading the selected project’s statistics…'; }
6096
+ }
6013
6097
  const USAGE_HOSTS = { claude: 'Claude Code', codex: 'Codex' };
6014
6098
  const USAGE_LOGS = { claude: '~/.claude/projects', codex: '~/.codex/sessions' };
6015
6099
  const USAGE_FN = { chat: 'Conversations', subagent: 'Subagents', automation: 'Automations', headless: 'Headless runs' };
@@ -6029,16 +6113,17 @@ function fmtUsdEq(n) {
6029
6113
  const fmtPct = (x) => (x == null ? null : `${Math.round(x * 100)}%`);
6030
6114
 
6031
6115
  async function loadUsage(days) {
6116
+ if (!currentProject) return; // init() loads again after resolving the project.
6032
6117
  if (days) usageDays = days;
6033
6118
  document.querySelectorAll('.usage-chip[data-days]').forEach((c) => c.classList.toggle('active', Number(c.dataset.days) === usageDays));
6034
6119
  clearTimeout(_usageTimer);
6035
- const request = ++_usageRequest, requestedDays = usageDays;
6120
+ const request = ++_usageRequest, requestedDays = usageDays, requestedProject = currentProject;
6036
6121
  const loadingBody = document.getElementById('usage-body');
6037
6122
  if (loadingBody && (!usageData || usageData.window?.days !== requestedDays)) {
6038
6123
  loadingBody.className = 'usage-note'; loadingBody.textContent = 'Loading session statistics…';
6039
6124
  }
6040
- const d = await api(`/api/usage?days=${usageDays}`);
6041
- if (request !== _usageRequest || requestedDays !== usageDays || currentTab !== 'usage') return;
6125
+ const d = await api(`/api/usage?days=${requestedDays}${pqs().replace('?', '&')}`);
6126
+ if (request !== _usageRequest || requestedDays !== usageDays || requestedProject !== currentProject || currentTab !== 'usage') return;
6042
6127
  const body = document.getElementById('usage-body');
6043
6128
  if (!body) return;
6044
6129
  if (!d || d.error) {
@@ -6052,9 +6137,9 @@ async function loadUsage(days) {
6052
6137
  _usageTimer = setTimeout(() => { if (currentTab === 'usage') loadUsage(); }, 2000);
6053
6138
  return;
6054
6139
  }
6055
- if (d.state !== 'counted') {
6140
+ if (d.state !== 'counted' || d.scope?.kind !== 'project') {
6056
6141
  body.className = 'usage-note';
6057
- body.textContent = `Usage is not available: ${d.why || d.state}`;
6142
+ body.textContent = `Usage is not available: ${d.why || (d.scope?.kind !== 'project' ? 'the server did not confirm project-scoped statistics; update the board' : d.state)}`;
6058
6143
  return;
6059
6144
  }
6060
6145
  usageData = d;
@@ -6083,7 +6168,7 @@ function usageLimitWindow(minutes) {
6083
6168
  function usageHostCard(d, host) {
6084
6169
  const name = USAGE_HOSTS[host];
6085
6170
  if (!d.seen || !d.seen[host]) {
6086
- return `<section class="usage-card" data-host="${host}"><h3>${name}</h3><p class="usage-note">No ${name} session logs on this machine (${USAGE_LOGS[host]}) — nothing to count, which is not the same as nothing used.</p></section>`;
6171
+ return `<section class="usage-card" data-host="${host}"><h3>${name}</h3><p class="usage-note">No ${name} session logs attributed to this project — nothing to count, which is not the same as nothing used.</p></section>`;
6087
6172
  }
6088
6173
  const h = d.hosts[host];
6089
6174
  const unpricedHere = (d.models || []).filter((m) => m.host === host && m.usd == null).map((m) => m.model);
@@ -6124,6 +6209,10 @@ function usageHostCard(d, host) {
6124
6209
  : `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('unloaded', usageRecorderHint(d).short)}</div>
6125
6210
  <div class="usage-sub">${hits.plan} refused at the plan limit · ${hits.server} server-side throttles (not your limit) in ${d.window.days} days · plan percentage: ${d.limits && d.limits.claude && d.limits.claude.recorder === 'on' ? 'from <code>claude</code> in a terminal' : '<code>great-cto statusline install</code>'}</div></div>`;
6126
6211
  }
6212
+ if (d.scope?.kind === 'project') {
6213
+ const hits = h.limitHits || { plan: 0, server: 0 };
6214
+ limit = `<div class="usage-sub">In this project: ${hits.plan} plan-limit refusals · ${hits.server} server-side throttles in ${d.window.days} days.</div>`;
6215
+ }
6127
6216
  return `<section class="usage-card" data-host="${host}">
6128
6217
  <h3><span>${name}</span><span class="muted">${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>
6129
6218
  <div class="usage-big" title="${esc(`${(h.tokens || 0).toLocaleString('en-US')} tokens, cache reads included`)}">${fmtTok(h.tokens)} <span class="muted" style="font-size:var(--fs-small)">tokens</span></div>
@@ -6174,7 +6263,7 @@ function usageTopTable(d, host) {
6174
6263
  const name = USAGE_HOSTS[host];
6175
6264
  const rows = (d.top && d.top[host]) || [];
6176
6265
  const head = `<h3><span>${name}: heaviest conversations</span><span class="muted">share of ${name} tokens</span></h3>`;
6177
- if (!rows.length) return `<section class="usage-card">${head}<p class="usage-note">${d.seen && d.seen[host] ? 'Nothing in this window.' : 'No logs on this machine.'}</p></section>`;
6266
+ if (!rows.length) return `<section class="usage-card">${head}<p class="usage-note">${d.seen && d.seen[host] ? 'Nothing in this window.' : 'No logs attributed to this project.'}</p></section>`;
6178
6267
  const money = host === 'claude';
6179
6268
  const body = rows.map((r) => {
6180
6269
  const tag = r.fn && r.fn !== 'chat' ? `<span class="usage-tag">${esc(USAGE_FN[r.fn] || r.fn)}</span>` : '';
@@ -6317,10 +6406,11 @@ function usageCodexLimits(d) {
6317
6406
  }
6318
6407
 
6319
6408
  // ── Agents and reviews (great_cto-egkw stage 3) ────────────────────────────
6320
- // Outcomes come from /api/outcomes (verdicts of every registered project, Beads
6409
+ // Outcomes come from /api/outcomes (verdicts of the selected project, Beads
6321
6410
  // bugs by priority); hook refusals and failures from /api/usage (Claude Code's
6322
6411
  // own logs). Counts only — no bug title reaches the page.
6323
6412
  let _outcomesTimer = null;
6413
+ let _outcomesRequest = 0;
6324
6414
  function fmtSpan(days) {
6325
6415
  if (days == null) return '—';
6326
6416
  const h = days * 24;
@@ -6332,7 +6422,10 @@ function fmtSpan(days) {
6332
6422
  async function loadOutcomes() {
6333
6423
  clearTimeout(_outcomesTimer);
6334
6424
  if (!document.getElementById('usage-outcomes')) return;
6335
- const d = await api(`/api/outcomes?days=${usageDays}`);
6425
+ const request = ++_outcomesRequest;
6426
+ const requestedProject = currentProject, requestedDays = usageDays, requestedHost = usageHost;
6427
+ const d = await api(`/api/outcomes?days=${requestedDays}${pqs().replace('?', '&')}`);
6428
+ if (request !== _outcomesRequest || requestedProject !== currentProject || requestedDays !== usageDays || requestedHost !== usageHost || currentTab !== 'usage') return;
6336
6429
  // Looked up AFTER the answer: Usage may have re-rendered while it was on the
6337
6430
  // way, and an answer written into the replaced element is an answer nobody sees.
6338
6431
  const el = document.getElementById('usage-outcomes');
@@ -6344,14 +6437,14 @@ async function loadOutcomes() {
6344
6437
  _outcomesTimer = setTimeout(() => { if (currentTab === 'usage') loadOutcomes(); }, 2000);
6345
6438
  return;
6346
6439
  }
6347
- if (d.state !== 'counted') { el.className = 'usage-note'; el.textContent = `Outcomes are not available: ${d.why || d.state}`; return; }
6440
+ if (d.state !== 'counted' || d.scope?.kind !== 'project') { el.className = 'usage-note'; el.textContent = `Project outcomes are not available: ${d.why || d.state}`; return; }
6348
6441
  el.className = 'usage-grid';
6349
6442
  el.innerHTML = usageAgentsCard(d) + usageFindingsCard(d);
6350
6443
  }
6351
6444
 
6352
6445
  function usageAgentsCard(d) {
6353
6446
  const A = d.agents || { agents: [] };
6354
- const head = `<h3><span>Agents: what they concluded</span><span class="muted">${d.projects} projects · ${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>`;
6447
+ const head = `<h3><span>Agents: what they concluded</span><span class="muted">this project · ${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>`;
6355
6448
  if (!A.agents.length) return `<section class="usage-card" data-outcomes="agents">${head}<p class="usage-note">No great_cto agent recorded a verdict in this period.</p></section>`;
6356
6449
  const dispatched = Object.fromEntries(((usageData && usageData.lists && usageData.lists.claude && usageData.lists.claude.agents) || []).map((a) => [a.name, a.n]));
6357
6450
  const rows = A.agents.map((a) => {
@@ -6391,7 +6484,7 @@ function usageFindingsCard(d) {
6391
6484
 
6392
6485
  function usageHooksCards(d) {
6393
6486
  const H = d.hooks;
6394
- if (!d.seen || !d.seen.claude) return '<p class="usage-note">No Claude Code logs on this machine — hook outcomes are read from them.</p>';
6487
+ if (!d.seen || !d.seen.claude) return '<p class="usage-note">No Claude Code logs attributed to this project — hook outcomes are read from them.</p>';
6395
6488
  if (!H) return '';
6396
6489
  const total = (rows) => (rows || []).reduce((n, r) => n + r.n, 0);
6397
6490
  return `<div class="usage-lists">
@@ -6411,8 +6504,7 @@ function renderUsage(d) {
6411
6504
  const hostBtn = (h) => `<button type="button" class="usage-chip${usageHost === h ? ' active' : ''}" aria-pressed="${usageHost === h}" onclick="setUsageHost('${h}')">${USAGE_HOSTS[h]}</button>`;
6412
6505
  body.innerHTML = `
6413
6506
  <div class="usage-grid">${usageHostCard(d, 'claude')}${usageHostCard(d, 'codex')}</div>
6414
- <h2 class="mp-section-label">Limits</h2>
6415
- <div class="usage-grid">${usageClaudeLimits(d)}${usageCodexLimits(d)}</div>
6507
+ <p class="usage-note">Statistics attributable to <b>${esc(currentProject || 'the current project')}</b> only, including existing linked Git worktrees. Sessions without a recoverable project identity are excluded; missing history is not proof of no activity. Account-wide subscription quotas are not project statistics and are not shown here.</p>
6416
6508
  <h2 class="mp-section-label">By day, by kind of work</h2>
6417
6509
  <div class="usage-grid">${usageDayChart(d, 'claude')}${usageDayChart(d, 'codex')}</div>
6418
6510
  <h2 class="mp-section-label">Heaviest conversations</h2>
@@ -6427,7 +6519,7 @@ function renderUsage(d) {
6427
6519
  ${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
6428
6520
  </div>
6429
6521
  <h2 class="mp-section-label">Agents and reviews</h2>
6430
- <div id="usage-outcomes" class="usage-note" role="status">Reading every project's verdicts and Beads.</div>
6522
+ <div id="usage-outcomes" class="usage-note" role="status">Reading this project's verdicts and Beads.</div>
6431
6523
  <h2 class="mp-section-label">Guards and hooks</h2>
6432
6524
  ${usageHooksCards(d)}
6433
6525
  <p class="usage-note">Read on this machine from ${USAGE_LOGS.claude} and ${USAGE_LOGS.codex}; nothing is sent anywhere. Tokens include cache reads. <b>$ list</b> is the API list-price equivalent from great_cto's price table, not a subscription bill — Claude Code's own estimate can differ for some models. A model without a price shows n/a, never $0.</p>`;
@@ -7372,7 +7464,7 @@ async function searchTranscripts() {
7372
7464
  const body = document.getElementById('sessions-body');
7373
7465
  if (q.length < 3) { loadSessions(); return; }
7374
7466
  body.innerHTML = '<div class="muted">Searching…</div>';
7375
- const d = await api(`/api/session-search${pqs()}&q=${encodeURIComponent(q)}`);
7467
+ const d = await api(`/api/session-search?q=${encodeURIComponent(q)}${pqs().replace('?', '&')}`);
7376
7468
  if (!d) { body.innerHTML = '<div class="muted">Search failed.</div>'; return; }
7377
7469
  if (!d.hits?.length) {
7378
7470
  body.innerHTML = `<div class="muted">No match for “${esc(q)}”.${d.note ? ' ' + esc(d.note) : ''}
@@ -7621,7 +7713,14 @@ async function switchProject(slug) {
7621
7713
  if (lbl) lbl.textContent = `loading ${slug}…`;
7622
7714
  // Close existing SSE so it doesn't push the wrong project's data
7623
7715
  if (window._gctoSSE) { try { window._gctoSSE.close(); } catch {} window._gctoSSE = null; }
7716
+ serverDefaultProject = false;
7624
7717
  currentProject = slug;
7718
+ document.getElementById('project-name').textContent = slug;
7719
+ document.getElementById('crumb-project').textContent = slug;
7720
+ document.title = `${slug} · great_cto`;
7721
+ resetProjectUsage();
7722
+ if (currentTab === 'usage') loadUsage();
7723
+ if (currentTab === 'skills') loadSkillsInventory();
7625
7724
  window.GctoWork.reset();
7626
7725
  // Optimistic UI clear
7627
7726
  allTasks = [];
@@ -7634,12 +7733,10 @@ async function switchProject(slug) {
7634
7733
  api(`/api/share${pqs()}`),
7635
7734
  api(`/api/inbox${pqs()}`),
7636
7735
  ]);
7736
+ if (slug !== currentProject) return;
7637
7737
  allTasks = tasks || [];
7638
7738
  metrics = met || {};
7639
7739
  shareState = share || { enabled: false, url: null };
7640
- document.getElementById('project-name').textContent = slug;
7641
- document.getElementById('crumb-project').textContent = slug;
7642
- document.title = `${slug} · great_cto`;
7643
7740
  const me = (allProjects || []).find(p => p.slug === slug);
7644
7741
  const arch = me?.archetype || 'default';
7645
7742
  const swIcon = document.getElementById('proj-switch-icon');
@@ -7833,7 +7930,11 @@ async function init() {
7833
7930
  || (allProjects[0]?.slug)
7834
7931
  || fromTaskId
7835
7932
  || 'project';
7933
+ serverDefaultProject = !urlProject && !(allProjects || []).some(p => p.slug === proj);
7836
7934
  currentProject = proj;
7935
+ resetProjectUsage();
7936
+ if (currentTab === 'usage') loadUsage();
7937
+ if (currentTab === 'skills') loadSkillsInventory();
7837
7938
  window.GctoWork.reset();
7838
7939
  document.getElementById('project-name').textContent = proj;
7839
7940
  document.getElementById('crumb-project').textContent = proj;
@@ -8498,7 +8599,7 @@ function renderShareState(state) {
8498
8599
  : ago < 1440 ? `${Math.floor(ago/60)}h ago`
8499
8600
  : `${Math.floor(ago/1440)}d ago`;
8500
8601
  const stale = ago > 60 * 24 ? ' · ⚠ data is stale — click ↻ Republish' : '';
8501
- metaEl.textContent = `Published ${agoStr} (${dt.toLocaleString()})${stale}`;
8602
+ metaEl.textContent = `Published ${agoStr} (${dt.toLocaleString('en-US')})${stale}`;
8502
8603
  } else if (metaEl) {
8503
8604
  metaEl.textContent = '';
8504
8605
  }
@@ -8917,6 +9018,7 @@ function cmdkActions() {
8917
9018
  { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
8918
9019
  { group: 'Go', label: 'Harness — host and second opinion', run: () => switchTab('harness') },
8919
9020
  { group: 'Go', label: 'Usage — what Claude Code and Codex consumed', run: () => switchTab('usage') },
9021
+ { group: 'Go', label: 'Skills — local inventory and document integrity', run: () => switchTab('skills') },
8920
9022
  { group: 'Go', label: 'Settings', run: () => switchTab('settings') },
8921
9023
  { group: 'Go', label: 'Tasks board (#/kanban, deep link)', run: () => switchTab('kanban') },
8922
9024
  { group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
@@ -9109,7 +9211,7 @@ try {
9109
9211
  // panels, not keys.
9110
9212
  const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
9111
9213
  const NAV = { inbox: 'decisions', budgets: 'ledger', agents: 'fleet', dashboard: 'ledger', share: 'settings', notifications: 'settings' };
9112
- const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, usage: 1, settings: 1, kanban: 1 };
9214
+ const ROUTES = { work: 1, history: 1, decisions: 1, ledger: 1, fleet: 1, harness: 1, usage: 1, skills: 1, settings: 1, kanban: 1 };
9113
9215
  function switchTab(id, el) {
9114
9216
  const lid = DEST[id] || id; // the panel's own name
9115
9217
  const navKey = NAV[id] || id; // the destination to highlight
@@ -9118,7 +9220,7 @@ function switchTab(id, el) {
9118
9220
  currentTab = lid;
9119
9221
  document.getElementById('nav-views-label').hidden = lid !== 'agents';
9120
9222
  document.querySelector('.nav-views').hidden = lid !== 'agents';
9121
- if (['agents', 'budgets', 'harness', 'usage'].includes(lid)) document.getElementById('tools-nav').open = true;
9223
+ if (['agents', 'budgets', 'harness', 'usage', 'skills'].includes(lid)) document.getElementById('tools-nav').open = true;
9122
9224
  if (lid === 'work' || lid === 'history') window.GctoWork.refresh();
9123
9225
  if (ROUTES[id]) {
9124
9226
  if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
@@ -9141,7 +9243,7 @@ function switchTab(id, el) {
9141
9243
  // crumb — `labels` used to be referenced here but is only ever a function-local in
9142
9244
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
9143
9245
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
9144
- const TAB_LABELS = { work: 'Work', history: 'History', decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', usage: 'Usage', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
9246
+ const TAB_LABELS = { work: 'Work', history: 'History', decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', usage: 'Usage', skills: 'Skills', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
9145
9247
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
9146
9248
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
9147
9249
  // fetched when Metrics opens — not only from the agents drill-in it used to
@@ -9175,6 +9277,7 @@ function switchTab(id, el) {
9175
9277
  if (lid === 'settings') loadSettingsPage();
9176
9278
  if (lid === 'harness') renderHarnesses();
9177
9279
  if (lid === 'usage') loadUsage();
9280
+ if (lid === 'skills') loadSkillsInventory();
9178
9281
  }
9179
9282
 
9180
9283
  // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
@@ -9983,9 +10086,11 @@ function relTime(iso) {
9983
10086
  if (s < 86400) return `${Math.round(s/3600)}h`;
9984
10087
  return `${Math.round(s/86400)}d`;
9985
10088
  }
9986
- function fmtDate(iso) { return iso ? new Date(iso).toLocaleString() : '—'; }
10089
+ // Board-owned copy and formatting stay English; task/document content is rendered
10090
+ // in its original language. Locale is fixed, but the operator's timezone is not.
10091
+ function fmtDate(iso) { return iso ? new Date(iso).toLocaleString('en-US') : '—'; }
9987
10092
  // Short date+time stamp for cards (full timestamp goes in the title attribute).
9988
- function fmtDT(iso) { return iso ? new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; }
10093
+ function fmtDT(iso) { return iso ? new Date(iso).toLocaleString('en-US', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; }
9989
10094
  // Money formatting — uses non-breaking thin space (U+202F) as thousands separator
9990
10095
  // to avoid the comma-as-decimal confusion ($195,960 reads as "195.96" in EU/RU
9991
10096
  // locales). Matches SI standard. Returns "1 234" not "1,234".
@@ -10147,7 +10252,7 @@ function renderNotifHistoryInline() {
10147
10252
  <span style="font-size:var(--fs-small);color:var(--text3);margin-left:8px">${esc(n.body)}</span>
10148
10253
  </div>
10149
10254
  <span style="font-size:var(--fs-caption);color:var(--text3);font-family:var(--mono);flex-shrink:0">
10150
- ${new Date(n.ts).toLocaleTimeString()}
10255
+ ${new Date(n.ts).toLocaleTimeString('en-US')}
10151
10256
  </span>
10152
10257
  </div>`).join('');
10153
10258
  }
@@ -783,7 +783,7 @@ if (paused) {
783
783
  // (b) Cost source explicitly comes from plans/verdicts (independent measurement)
784
784
  const savingsTrustworthy = realLlmUsd > 0 || (m.cost?.source && m.cost.source !== 'tasks');
785
785
  const effectiveSavings = savingsTrustworthy && effectiveLlmUsd > 0 && effectiveHumanUsd > 0
786
- ? Math.round(effectiveHumanUsd / effectiveLlmUsd).toLocaleString() + '×' : null;
786
+ ? Math.round(effectiveHumanUsd / effectiveLlmUsd).toLocaleString('en-US') + '×' : null;
787
787
  const effectiveAiH = totalAiMin > 0 ? totalAiMin / 60 : totalTimeMin / 60;
788
788
 
789
789
  // Hero narrative — focus on cost difference, not time
@@ -800,7 +800,7 @@ if (paused) {
800
800
  // Human equivalent: standard 4h per feature × $150/hr
801
801
  const humanCost = done * 4 * 150;
802
802
  if (aiCostDisplay && humanCost > 0) {
803
- line += `. Human team estimate: $${humanCost.toLocaleString()}.`;
803
+ line += `. Human team estimate: $${humanCost.toLocaleString('en-US')}.`;
804
804
  }
805
805
  return line;
806
806
  })();
@@ -1037,7 +1037,7 @@ if (paused) {
1037
1037
  const costRatio = (effectiveHumanUsd > 0 && effectiveLlmUsd > 0)
1038
1038
  ? effectiveHumanUsd / effectiveLlmUsd : 0;
1039
1039
  const costRatioStr = costRatio >= 2
1040
- ? Math.round(costRatio).toLocaleString() + '×'
1040
+ ? Math.round(costRatio).toLocaleString('en-US') + '×'
1041
1041
  : null;
1042
1042
  if (effectiveSavings) {
1043
1043
  return `<div class="big">${effectiveSavings} cheaper</div>
@@ -1058,7 +1058,7 @@ if (paused) {
1058
1058
  <div class="cost-bar-row human">
1059
1059
  <div class="cb-name">Humans</div>
1060
1060
  <div class="cb-rail"><div class="cb-fill" style="width:${humanPct.toFixed(1)}%"></div></div>
1061
- <div class="cb-val">$${Number((effectiveHumanUsd > 0 ? effectiveHumanUsd : humanH * 150).toFixed(0)).toLocaleString()}</div>
1061
+ <div class="cb-val">$${Number((effectiveHumanUsd > 0 ? effectiveHumanUsd : humanH * 150).toFixed(0)).toLocaleString('en-US')}</div>
1062
1062
  </div>
1063
1063
  <div class="cost-bar-row ai">
1064
1064
  <div class="cb-name">AI</div>
@@ -60,7 +60,7 @@ function verdictFiles(dir) {
60
60
  * Per-agent outcomes in the window.
61
61
  * @param {{projects:{name:string,path:string}[], globalDir?:string, days?:number, now?:number, roster?:string[]}} opts
62
62
  */
63
- export function agentOutcomes({ projects, globalDir = path.join(os.homedir(), '.great_cto', 'verdicts'), days = 30, now = Date.now(), roster = [] }) {
63
+ export function agentOutcomes({ projects, globalDir = path.join(os.homedir(), '.great_cto', 'verdicts'), days = 30, now = Date.now(), roster = [], projectScope = false }) {
64
64
  const from = now - days * DAY;
65
65
  const known = new Set(roster);
66
66
  const seen = new Set();
@@ -78,6 +78,11 @@ export function agentOutcomes({ projects, globalDir = path.join(os.homedir(), '.
78
78
  const p = parseVerdictLine(line);
79
79
  if (!p.ok) continue;
80
80
  const rec = p.rec;
81
+ // Local placement supplies attribution only when the record has no tag.
82
+ // An explicit foreign tag cannot be overridden by a copied local log;
83
+ // untagged global records remain unattributable.
84
+ if (projectScope && (project === null || rec.project != null)
85
+ && !projects.some(p => (p.aliases || [p.name, p.path]).includes(rec.project))) continue;
81
86
  const t = Date.parse(rec.ts);
82
87
  if (!Number.isFinite(t) || t < from || t > now) continue;
83
88
  const key = line.trim();
@@ -207,13 +212,13 @@ export async function bugFindings({ projects, days = 30, now = Date.now(), list
207
212
  const sum = (o) => Object.values(o).reduce((a, n) => a + n, 0);
208
213
 
209
214
  /** Both, for the board. */
210
- export async function outcomes({ days = 30, now = Date.now(), roster = [], projects = registeredProjects(), globalDir, list } = {}) {
215
+ export async function outcomes({ days = 30, now = Date.now(), roster = [], projects = registeredProjects(), globalDir, list, projectScope = false } = {}) {
211
216
  const findings = await bugFindings({ projects, days, now, list });
212
217
  return {
213
218
  state: 'counted',
214
219
  window: { days, from: dayOf(now - (days - 1) * DAY), to: dayOf(now) },
215
220
  projects: projects.length,
216
- agents: agentOutcomes({ projects, globalDir, days, now, roster }),
221
+ agents: agentOutcomes({ projects, globalDir, days, now, roster, projectScope }),
217
222
  findings,
218
223
  };
219
224
  }
@@ -86,6 +86,68 @@ export function projectName(cwd, homeDir = home()) {
86
86
  return path.basename(cwd) || null;
87
87
  }
88
88
 
89
+ const canonical = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
90
+
91
+ /** Stable repository identity, including linked worktrees. Never infer it from a title/basename. */
92
+ function repositoryIdentity(cwd) {
93
+ for (let dir = canonical(cwd); ; dir = path.dirname(dir)) {
94
+ const marker = path.join(dir, '.git');
95
+ try {
96
+ const stat = fs.statSync(marker);
97
+ let gitDir;
98
+ if (stat.isDirectory()) gitDir = marker;
99
+ else {
100
+ const match = fs.readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+)\s*$/m);
101
+ if (!match) return null;
102
+ gitDir = path.resolve(dir, match[1].trim());
103
+ }
104
+ let common = gitDir;
105
+ try { common = path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); } catch { /* main checkout */ }
106
+ return { common: canonical(common), root: dir };
107
+ } catch { /* continue to parent */ }
108
+ if (path.dirname(dir) === dir) return null;
109
+ }
110
+ }
111
+
112
+ /** Filter before aggregation: children without cwd inherit only their own host's parent. */
113
+ export function projectUsageIndex(index, projectPath) {
114
+ if (!projectPath) return index;
115
+ const root = canonical(projectPath);
116
+ const git = repositoryIdentity(root);
117
+ const projectRelative = git ? path.relative(git.root, root) : null;
118
+ const identities = new Map();
119
+ const entries = Object.entries(index.files || {});
120
+ const sessions = new Map();
121
+ for (const [, e] of entries) if (e.meta?.session) sessions.set(`${e.host}|${e.meta.session}`, e);
122
+ const inheritedCwd = e => {
123
+ const visited = new Set();
124
+ while (e) {
125
+ if (e.meta?.cwd) return e.meta.cwd;
126
+ const key = `${e.host}|${e.meta?.parent}`;
127
+ if (!e.meta?.parent || visited.has(key)) return null;
128
+ visited.add(key); e = sessions.get(key);
129
+ }
130
+ return null;
131
+ };
132
+ const matches = (cwd) => {
133
+ if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
134
+ if (identities.has(cwd)) return identities.get(cwd);
135
+ const actual = canonical(cwd);
136
+ const repo = repositoryIdentity(actual);
137
+ // Transfer the selected project's relative directory to this worktree.
138
+ // A shared Git database alone does not make sibling monorepo apps one project.
139
+ const scopeRoot = git && repo && git.common === repo.common
140
+ ? path.resolve(repo.root, projectRelative)
141
+ : root;
142
+ const rel = path.relative(scopeRoot, actual);
143
+ const inside = rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
144
+ const match = git && repo ? git.common === repo.common && inside : inside;
145
+ identities.set(cwd, match);
146
+ return match;
147
+ };
148
+ return { ...index, files: Object.fromEntries(entries.filter(([, e]) => matches(inheritedCwd(e)))) };
149
+ }
150
+
89
151
  // ── file discovery ────────────────────────────────────────────────────────────
90
152
 
91
153
  function walk(dir, maxDepth, out) {
@@ -823,7 +885,8 @@ function laneDetail(samples, { fromMs, now }) {
823
885
  * @param {object} index from scanUsage
824
886
  * @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
825
887
  */
826
- export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off' } = {}) {
888
+ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off', projectPath = null } = {}) {
889
+ index = projectUsageIndex(index, projectPath);
827
890
  const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
828
891
  const dates = [];
829
892
  for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
@@ -918,7 +981,7 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
918
981
  // The conversation's own file names it; a subagent's file only fills a gap.
919
982
  const own = !e.meta?.parent;
920
983
  if (own || !s.title) s.title = (host === 'codex' ? codexTitles[e.meta?.session] : null) || e.meta?.title || e.meta?.prompt || s.title;
921
- if (own || !s.project) s.project = projectName(e.meta?.cwd) || s.project;
984
+ if (own || !s.project) s.project = projectName(projectPath || e.meta?.cwd) || s.project;
922
985
  if (own || !s.fn) s.fn = fn;
923
986
  }
924
987
 
@@ -940,6 +1003,11 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
940
1003
 
941
1004
  return {
942
1005
  state: 'counted',
1006
+ ...(projectPath ? {
1007
+ scope: { kind: 'project', path: canonical(projectPath) },
1008
+ files: Object.keys(index.files || {}).length,
1009
+ seen: Object.fromEntries(['claude', 'codex'].map(host => [host, Object.values(index.files || {}).some(e => e.host === host)])),
1010
+ } : {}),
943
1011
  window: { from, to: dates[dates.length - 1], days: span },
944
1012
  hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
945
1013
  tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
@@ -957,8 +1025,8 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
957
1025
  tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
958
1026
  }])),
959
1027
  limits: {
960
- codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
961
- claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
1028
+ codex: !projectPath && codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
1029
+ claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: projectPath ? null : claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
962
1030
  },
963
1031
  unpricedModels: [...unpriced].sort(),
964
1032
  // Claude Code only: Codex does not write hook outcomes to its session logs.