diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7fd279b..19e50d361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.20.0] - 2026-11-01 + +**`/retro global` now sees Kimi Code, OMP and Antigravity sessions (#2917, #1977).** `gstack-global-discover` scanned only Claude Code, Codex and Gemini. A week spent in other agent CLIs went uncounted, and nothing showed a zero to notice. + +### Added +- **Kimi Code** (`~/.kimi-code/session_index.jsonl`, window by `state.json` updatedAt, repo by workDir) and **OMP** (`~/.omp/agent/sessions/*/*.jsonl`, window by file mtime, repo by the cwd in the session file; `~/.omp/logs` is never counted) are counted per repo. +- **Antigravity** (`~/.gemini/antigravity-cli/conversations/*.db`) is counted by the later of each database's and its `-wal` file's mtime. Its sessions count in `total_sessions` but belong to no repo, because the CLI stores the working directory inside an undocumented blob. + +### Changed +- **One scanner registry** drives the JSON, the summary and the stderr line. Every registered tool appears in `tools`, zeros included, with a `label`. Antigravity's entry carries `unattributed: true`. Existing keys (`claude_code`, `codex`, `gemini`) keep their paths, and each repo's `sessions` lists every attributed tool. +- **/retro global** builds its session counts and Tool Usage Analysis from that list. A tool missing from an older saved retro counts as 0. + +Thanks to @richardiitse (#2917) and @Akagilnc (#1977) for the reports and the store layouts. + ## [1.92.19.0] - 2026-11-01 **On Linux and Windows the renderer goes straight to gstack's own browser instead of asking for Aside (#2864).** `probeAside()` in `lib/aside-render.ts`, used by /make-pdf, /diagram and `gstack-render`, used to run `aside --version` on any platform. When that failed it told the user to install a browser that only ships for macOS. Off macOS it now answers `NEEDS_ASIDE` with "Aside is macOS-only (this is linux)", without starting a process, and the renderer moves on to gstack's own browser. macOS behavior and `GSTACK_SKIP_ASIDE=1` are unchanged. diff --git a/README.md b/README.md index f140ad1c6..c10f1ae0f 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/eval-plan` | **Eval Planner** | Preregister a paid eval before the first priced call: bars, arms, decoys and stop rules, a $0 stub-model dry run over the real inputs, and a three-item priced pilot whose extrapolation and spend table gate the approval. | | `/document-release` | **Technical Writer** | Audit changed behavior against project docs on every ship, before final verification and publication. Also runs standalone. Shows updated, reviewed/current or blocked docs and any remaining gaps. | | `/document-generate` | **Documentation Author** | Generate missing docs from scratch using the Diataxis framework. Researches the codebase first, then writes reference / how-to / tutorial / explanation docs that actually match the code. Invokable standalone or chained from `/document-release` when the coverage map finds gaps. Learn more: [tutorial](docs/tutorial-document-generate.md) • [how-to](docs/howto-document-a-shipped-feature.md) • [why Diataxis](docs/explanation-diataxis-in-gstack.md). | -| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). | +| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini, Kimi Code, OMP, Antigravity). | | `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. | | `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. | | `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. | diff --git a/TODOS.md b/TODOS.md index 99cef6db2..cdbabf43e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -82,7 +82,7 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the The 73 open feature requests (75 with #3061 and #3122, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter. -- **New hosts (8 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` discovery of Antigravity (#1977) and Kimi Code / OMP sessions (#2917). Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3. +- **New hosts (7 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` repo attribution for Antigravity sessions (#1977: they are counted now, but the working directory sits in an undocumented blob). Kimi Code and OMP discovery (#2917) shipped with the scanner registry in `bin/gstack-global-discover.ts`. Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3. - **Provider pluralism (7 issues, plus #3122)** — Gemini CLI as an outside-voice backend (#619), more image providers in `design` beyond OpenAI and Gemini (Gemini shipped for #990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), Codex refresh-token recovery docs (#1542), and a configurable PR review bot instead of hard-coded Greptile in /review, /ship and /retro (#3122, folded here by decision D4). One provider seam serves all of them. **Priority:** P3. - **Install footprint and layout (7 issues)** — skills.sh distribution (#113), `~/.gstack` as the global install path (#753), a repo-local install that keeps state in the project (#1235), five compiled binaries each bundling Bun (#1245), Windows junctions instead of copies (#1877), the 1.1 GB `~/.claude/skills` footprint (#2199), and a skill-only mode (#2528). Decision D6: setup keeps the staged copy on Windows, because it works today and links change what every cleanup deletes; #1943 (@JonasFocus, junctions) and #2129 (@TTmo123, symlinks with the privilege check) are closed with credit and their designs stay here as the starting point. See also "install footprint follow-ups from the October fix wave". **Priority:** P3. - **New skills (12 issues)** — GUI kanban for plan reviews (#247), journey maps (#729), UI-doc review (#851), assumption verification `/avl` (#1058), `/a11y-check` (#1104), `/env-check` (#1106), native plan writing (#1170), `/plan-experiment` and `/review-experiment` (#1195), `/plan-status` (#1343), language-specific reviewers (#1753), `/benchmark-mobile` (#1754), session start/end skills (#2088). **Priority:** P3. diff --git a/VERSION b/VERSION index 42b0e21c8..dbf7a7040 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.19.0 +1.92.20.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 02a0cf306..45e74e9c0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.19.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.20.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index 88cfbfdbe..3fdf815c9 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -10,7 +10,7 @@ */ import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; -import { join, basename, dirname, resolve } from "path"; +import { join, basename, dirname, resolve, isAbsolute } from "path"; import { spawnSync } from "child_process"; import { homedir } from "os"; import { canonicalRemote } from "../lib/remote-identity"; @@ -20,8 +20,11 @@ import { canonicalRemote } from "../lib/remote-identity"; type CodexOriginator = "cli" | "desktop" | "exec" | "claude_code" | "other"; type CodexOriginatorCounts = Record; +type ToolId = "claude_code" | "codex" | "gemini" | "kimi_code" | "omp"; +type ToolCounts = Record; + interface Session { - tool: "claude_code" | "codex" | "gemini"; + tool: ToolId; cwd: string; codexOriginator?: CodexOriginator; } @@ -30,19 +33,24 @@ interface Repo { name: string; remote: string; paths: string[]; - sessions: { claude_code: number; codex: number; gemini: number }; + sessions: ToolCounts; codex_originators: CodexOriginatorCounts; } +interface ToolSummary { + label: string; + total_sessions: number; + repos: number; + originators?: CodexOriginatorCounts; + /** Sessions counted without a recorded cwd: they count in total_sessions, like sessions outside any repo, but belong to no repo. */ + unattributed?: true; +} + interface DiscoveryResult { window: string; start_date: string; repos: Repo[]; - tools: { - claude_code: { total_sessions: number; repos: number }; - codex: { total_sessions: number; repos: number; originators: CodexOriginatorCounts }; - gemini: { total_sessions: number; repos: number }; - }; + tools: Record; total_sessions: number; total_repos: number; } @@ -521,6 +529,128 @@ function scanGemini(since: Date): Session[] { return sessions; } +/** + * Kimi Code: ~/.kimi-code/session_index.jsonl lists { sessionId, sessionDir, workDir }; + * the session's state.json carries updatedAt (epoch ms). workDir is the absolute cwd. + */ +function scanKimiCode(since: Date): Session[] { + const root = join(homedir(), ".kimi-code"); + let index: string; + try { + index = readFileSync(join(root, "session_index.jsonl"), { encoding: "utf-8" }); + } catch { + return []; + } + const sessions: Session[] = []; + for (const line of index.split(/\r?\n/)) { + if (!line.trim()) continue; + let entry: any; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (typeof entry?.sessionDir !== "string" || !entry.sessionDir) continue; + const dir = isAbsolute(entry.sessionDir) ? entry.sessionDir : join(root, entry.sessionDir); + let state: any = {}; + try { + state = JSON.parse(readFileSync(join(dir, "state.json"), { encoding: "utf-8" })); + } catch {} + let updated = typeof state?.updatedAt === "number" ? state.updatedAt : NaN; + if (Number.isNaN(updated)) { + try { + updated = statSync(dir).mtimeMs; + } catch { + continue; + } + } + if (updated < since.getTime()) continue; + const cwd = typeof entry.workDir === "string" && entry.workDir ? entry.workDir : typeof state?.cwd === "string" ? state.cwd : null; + if (cwd) sessions.push({ tool: "kimi_code", cwd }); + } + return sessions; +} + +/** + * OMP: one _.jsonl per session under ~/.omp/agent/sessions//. + * The workspace directory name is a lossy encoding of the path, so the cwd comes from + * the session file itself. A session counts when its file was written inside the + * window, like the other scanners; ~/.omp/logs holds per-launch logs and is never read. + */ +function scanOmp(since: Date, stats: CwdScanStats): Session[] { + const sessionsDir = join(homedir(), ".omp", "agent", "sessions"); + let workspaces: string[]; + try { + workspaces = readdirSync(sessionsDir); + } catch { + return []; + } + const sessions: Session[] = []; + for (const workspace of workspaces) { + const dir = join(sessionsDir, workspace); + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + for (const file of files) { + const path = join(dir, file); + try { + if (statSync(path).mtime < since) continue; + } catch { + continue; + } + const cwd = extractCwdFromJsonl(path, stats); + if (cwd) sessions.push({ tool: "omp", cwd }); + } + } + return sessions; +} + +/** + * Antigravity CLI (agy) keeps each conversation as SQLite under + * ~/.gemini/antigravity-cli/conversations/*.db, with the cwd inside an undocumented + * blob (#1977). Sessions are counted by the later of the .db and its -wal file's + * mtime and are not attributed to a repo. + */ +function countAntigravity(since: Date): number { + const dir = join(homedir(), ".gemini", "antigravity-cli", "conversations"); + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".db")); + } catch { + return 0; + } + let count = 0; + for (const file of files) { + let latest = 0; + for (const path of [join(dir, file), join(dir, `${file}-wal`)]) { + try { + latest = Math.max(latest, statSync(path).mtimeMs); + } catch {} + } + if (latest >= since.getTime()) count++; + } + return count; +} + +const SCANNERS: ReadonlyArray<{ id: ToolId; label: string; scan: (since: Date, stats: CwdScanStats) => Session[] }> = [ + { id: "claude_code", label: "CC", scan: scanClaudeCode }, + { id: "codex", label: "Codex", scan: (since) => scanCodex(since) }, + { id: "gemini", label: "Gemini", scan: (since) => scanGemini(since) }, + { id: "kimi_code", label: "Kimi Code", scan: (since) => scanKimiCode(since) }, + { id: "omp", label: "OMP", scan: scanOmp }, +]; + +function emptyCounts(): ToolCounts { + return Object.fromEntries(SCANNERS.map((s) => [s.id, 0])) as ToolCounts; +} + +function countTotal(counts: ToolCounts): number { + return SCANNERS.reduce((n, s) => n + counts[s.id], 0); +} + // ── Deduplication ────────────────────────────────────────────────────────── async function resolveAndDeduplicate(sessions: Session[]): Promise { @@ -555,7 +685,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise { } } - const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 }; + const sessionCounts = emptyCounts(); for (const s of data.sessions) { sessionCounts[s.tool]++; } @@ -570,11 +700,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise { } // Sort by total sessions descending - repos.sort( - (a, b) => - b.sessions.claude_code + b.sessions.codex + b.sessions.gemini - - (a.sessions.claude_code + a.sessions.codex + a.sessions.gemini) - ); + repos.sort((a, b) => countTotal(b.sessions) - countTotal(a.sessions)); return repos; } @@ -588,21 +714,19 @@ async function main() { // Run all scanners const cwdStats: CwdScanStats = { oversizedLines: 0 }; - const ccSessions = scanClaudeCode(sinceDate, cwdStats); - const codexSessions = scanCodex(sinceDate); - const geminiSessions = scanGemini(sinceDate); - - const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions]; + const byTool = SCANNERS.map((s) => ({ ...s, sessions: s.scan(sinceDate, cwdStats) })); + const agySessions = countAntigravity(sinceDate); + const allSessions = byTool.flatMap((t) => t.sessions); + const codexSessions = byTool.find((t) => t.id === "codex")!.sessions; + const toolCounts = byTool.map((t) => `${t.label}: ${t.sessions.length}`).join(", "); // Summary to stderr - console.error( - `Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions` - ); + console.error(`Discovered: ${toolCounts}; Antigravity: ${agySessions} (repo unattributed)`); if (cwdStats.oversizedLines > 0) { console.error( - `Skipped ${cwdStats.oversizedLines} Claude Code JSONL line(s) over 1 MiB while reading session cwds; ` + - `a session whose cwd appears only after such lines counts with its project directory's other sessions.`, + `Skipped ${cwdStats.oversizedLines} session JSONL line(s) over 1 MiB while reading session cwds; ` + + `a Claude Code session whose cwd appears only after such lines counts with its project directory's other sessions.`, ); } @@ -611,23 +735,25 @@ async function main() { console.error(`→ ${repos.length} unique repos`); - // Count per-tool repo counts - const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size; - const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size; - const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size; - const codexOriginators = countCodexOriginators(codexSessions); + const tools = Object.fromEntries( + byTool.map((t) => [ + t.id, + { + label: t.label, + total_sessions: t.sessions.length, + repos: repos.filter((r) => r.sessions[t.id] > 0).length, + ...(t.id === "codex" ? { originators: codexOriginators } : {}), + }, + ]), + ) as Record; const result: DiscoveryResult = { window: since, start_date: startDate, repos, - tools: { - claude_code: { total_sessions: ccSessions.length, repos: ccRepos }, - codex: { total_sessions: codexSessions.length, repos: codexRepos, originators: codexOriginators }, - gemini: { total_sessions: geminiSessions.length, repos: geminiRepos }, - }, - total_sessions: allSessions.length, + tools: { ...tools, agy: { label: "Antigravity", total_sessions: agySessions, repos: 0, unattributed: true } }, + total_sessions: allSessions.length + agySessions, total_repos: repos.length, }; @@ -636,17 +762,16 @@ async function main() { } else { // Summary format console.log(`Window: ${since} (since ${startDate})`); - console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`); + console.log(`Sessions: ${allSessions.length + agySessions} total (${toolCounts}, Antigravity: ${agySessions})`); if (codexSessions.length > 0) console.log(` Codex originators: ${formatCodexOriginators(codexOriginators)}`); + if (agySessions > 0) console.log(` Antigravity sessions are not attributed to a repo`); console.log(`Repos: ${repos.length} unique`); console.log(""); for (const repo of repos) { - const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini; - const tools = []; - if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`); - if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})`); - if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`); - console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`); + const parts = byTool + .filter((t) => repo.sessions[t.id] > 0) + .map((t) => (t.id === "codex" ? `Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})` : `${t.label}:${repo.sessions[t.id]}`)); + console.log(` ${repo.name} (${countTotal(repo.sessions)} sessions) — ${parts.join(", ")}`); console.log(` Remote: ${repo.remote}`); console.log(` Paths: ${repo.paths.join(", ")}`); } diff --git a/package.json b/package.json index cf7198c84..3d7d98fac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.19", + "version": "1.92.20", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/retro/SKILL.md b/retro/SKILL.md index 7607330d1..625f93ea4 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -728,7 +728,7 @@ From the commit timestamps gathered in Step 3, group by date. For each date, cou From the discovery JSON, analyze tool usage patterns: - Which AI tool is used for which repos (exclusive vs. shared) -- Session count per tool +- Session count per tool (each discovery `tools` entry, by `label`) - Behavioral patterns (e.g., "Codex used exclusively for myapp, Claude Code for everything else") ### Global Step 7: Aggregate and draft narrative @@ -757,7 +757,7 @@ pad names to the longest name and never truncate them. ║ ║ [N] commits across [M] projects ║ +[X]k LOC added · [Y]k LOC deleted · [Z]k net -║ [N] AI coding sessions (CC: X, Codex: Y, Gemini: Z) +║ [N] AI coding sessions ([label]: X per tool) ║ [N]-day shipping streak 🔥 ║ ║ PROJECTS @@ -804,7 +804,7 @@ Full team/project analysis follows the personal card. | Projects active | N | | Total commits (all repos, all contributors) | N | | Total LOC | +N / -N | -| AI coding sessions | N (CC: X, Codex: Y, Gemini: Z) | +| AI coding sessions | N ([label]: X per tool) | | Active days | N | | Global shipping streak (any contributor, any repo) | N consecutive days | | Context switches/day | N avg (max: M) | @@ -844,9 +844,8 @@ Format: ### Tool Usage Analysis Per-tool breakdown with behavioral patterns: -- Claude Code: N sessions across M repos — patterns observed -- Codex: N sessions across M repos — patterns observed -- Gemini: N sessions across M repos — patterns observed +- [label]: N sessions across M repos — patterns observed +- `unattributed` (Antigravity): sessions, no repos ### Ship of the Week (Global) Highest-impact PR across ALL projects. Identify by LOC and commit messages. @@ -869,7 +868,7 @@ ls -t "$GSTACK_STATE_ROOT"/retros/global-*.json 2>/dev/null | head -5 **Only compare against a prior retro with the same `window` value** (e.g., 7d vs 7d). If the most recent prior retro has a different window, skip comparison and note: "Prior global retro used a different window — skipping comparison." -If a matching prior retro exists, load it with the Read tool. Show a **Trends vs Last Global Retro** table with deltas for key metrics: total commits, LOC, sessions, streak, context switches/day. +If a matching prior retro exists, load it with the Read tool. Show a **Trends vs Last Global Retro** table with deltas for key metrics: total commits, LOC, sessions, streak, context switches/day. Missing tools count as 0. If no prior global retros exist, append: "First global retro recorded — run again next week to see trends." @@ -913,7 +912,7 @@ Use the Write tool to save JSON to the printed `RETRO_FILE`: "deletions": 4200, "projects": 5, "active_days": 6, - "sessions": { "claude_code": 48, "codex": 8, "gemini": 3 }, + "sessions": { "claude_code": 48, "codex": 8, "gemini": 3, "omp": 1 }, "global_streak_days": 52, "avg_context_switches_per_day": 2.1 }, diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 4dbc9bf97..f2e5861ce 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -549,7 +549,7 @@ From the commit timestamps gathered in Step 3, group by date. For each date, cou From the discovery JSON, analyze tool usage patterns: - Which AI tool is used for which repos (exclusive vs. shared) -- Session count per tool +- Session count per tool (each discovery `tools` entry, by `label`) - Behavioral patterns (e.g., "Codex used exclusively for myapp, Claude Code for everything else") ### Global Step 7: Aggregate and draft narrative @@ -578,7 +578,7 @@ pad names to the longest name and never truncate them. ║ ║ [N] commits across [M] projects ║ +[X]k LOC added · [Y]k LOC deleted · [Z]k net -║ [N] AI coding sessions (CC: X, Codex: Y, Gemini: Z) +║ [N] AI coding sessions ([label]: X per tool) ║ [N]-day shipping streak 🔥 ║ ║ PROJECTS @@ -625,7 +625,7 @@ Full team/project analysis follows the personal card. | Projects active | N | | Total commits (all repos, all contributors) | N | | Total LOC | +N / -N | -| AI coding sessions | N (CC: X, Codex: Y, Gemini: Z) | +| AI coding sessions | N ([label]: X per tool) | | Active days | N | | Global shipping streak (any contributor, any repo) | N consecutive days | | Context switches/day | N avg (max: M) | @@ -665,9 +665,8 @@ Format: ### Tool Usage Analysis Per-tool breakdown with behavioral patterns: -- Claude Code: N sessions across M repos — patterns observed -- Codex: N sessions across M repos — patterns observed -- Gemini: N sessions across M repos — patterns observed +- [label]: N sessions across M repos — patterns observed +- `unattributed` (Antigravity): sessions, no repos ### Ship of the Week (Global) Highest-impact PR across ALL projects. Identify by LOC and commit messages. @@ -690,7 +689,7 @@ ls -t "$GSTACK_STATE_ROOT"/retros/global-*.json 2>/dev/null | head -5 **Only compare against a prior retro with the same `window` value** (e.g., 7d vs 7d). If the most recent prior retro has a different window, skip comparison and note: "Prior global retro used a different window — skipping comparison." -If a matching prior retro exists, load it with the Read tool. Show a **Trends vs Last Global Retro** table with deltas for key metrics: total commits, LOC, sessions, streak, context switches/day. +If a matching prior retro exists, load it with the Read tool. Show a **Trends vs Last Global Retro** table with deltas for key metrics: total commits, LOC, sessions, streak, context switches/day. Missing tools count as 0. If no prior global retros exist, append: "First global retro recorded — run again next week to see trends." @@ -734,7 +733,7 @@ Use the Write tool to save JSON to the printed `RETRO_FILE`: "deletions": 4200, "projects": 5, "active_days": 6, - "sessions": { "claude_code": 48, "codex": 8, "gemini": 3 }, + "sessions": { "claude_code": 48, "codex": 8, "gemini": 3, "omp": 1 }, "global_streak_days": 52, "avg_context_switches_per_day": 2.1 }, diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index 8e56a6b42..ffaf6a8b7 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, utimesSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { spawnSync } from "child_process"; @@ -417,11 +417,9 @@ describe("gstack-global-discover", () => { const json = JSON.parse(result.stdout); // Total sessions should equal sum across tools - const toolTotal = - json.tools.claude_code.total_sessions + - json.tools.codex.total_sessions + - json.tools.gemini.total_sessions; + const toolTotal = Object.values(json.tools as Record).reduce((n, t) => n + t.total_sessions, 0); expect(json.total_sessions).toBe(toolTotal); + for (const key of ["claude_code", "codex", "gemini", "kimi_code", "omp", "agy"]) expect(json.tools).toHaveProperty(key); }); test("deduplicates Conductor workspaces by remote", () => { @@ -439,6 +437,108 @@ describe("gstack-global-discover", () => { }); }); + describe("Kimi Code, OMP and Antigravity sessions (#2917, #1977)", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "gstack-more-tools-")); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + const gitEnv = () => ({ ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" }); + function repo(rel: string, remote: string): string { + const dir = join(home, rel); + mkdirSync(dir, { recursive: true }); + for (const args of [["init", "-q"], ["commit", "-q", "--allow-empty", "-m", "init"], ["remote", "add", "origin", remote]]) { + expect(spawnSync("git", ["-C", dir, ...args], { encoding: "utf-8", timeout: 30_000, env: gitEnv() }).status).toBe(0); + } + return dir; + } + const old = new Date(Date.now() - 3 * 24 * 3600 * 1000); + function kimi(id: string, workDir: string, updatedAt: number): void { + const dir = join(home, ".kimi-code", "sessions", "ws-1", id); + mkdirSync(join(dir, "agents", "main"), { recursive: true }); + writeFileSync(join(dir, "state.json"), JSON.stringify({ cwd: workDir, createdAt: updatedAt - 1000, updatedAt })); + writeFileSync(join(dir, "agents", "main", "wire.jsonl"), JSON.stringify({ created_at: new Date(updatedAt).toISOString() }) + "\n"); + const index = join(home, ".kimi-code", "session_index.jsonl"); + const prev = existsSync(index) ? readFileSync(index, "utf-8") : ""; + writeFileSync(index, prev + JSON.stringify({ sessionId: id, sessionDir: dir, workDir }) + "\n"); + } + function omp(name: string, cwd: string, mtime?: Date): string { + const dir = join(home, ".omp", "agent", "sessions", "-work-proj"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, name); + writeFileSync(file, [JSON.stringify({ type: "session", cwd, timestamp: new Date().toISOString() }), JSON.stringify({ type: "message", timestamp: new Date().toISOString() })].join("\n") + "\n"); + if (mtime) utimesSync(file, mtime, mtime); + return file; + } + function agy(name: string, mtime?: Date, walMtime?: Date): void { + const dir = join(home, ".gemini", "antigravity-cli", "conversations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), ""); + if (mtime) utimesSync(join(dir, name), mtime, mtime); + if (walMtime) { + writeFileSync(join(dir, `${name}-wal`), ""); + utimesSync(join(dir, `${name}-wal`), walMtime, walMtime); + } + } + function discover(format: "json" | "summary" = "json") { + const r = spawnSync("bun", ["run", scriptPath, "--since", "1d", "--format", format], { + encoding: "utf-8", + timeout: 60_000, + env: { ...gitEnv(), HOME: home, USERPROFILE: home, CODEX_SESSIONS_DIR: join(home, "no-codex") }, + }); + expect(r.status).toBe(0); + return { stdout: r.stdout, stderr: r.stderr }; + } + + test("with no stores, every registered tool is reported with a zero count", () => { + const json = JSON.parse(discover().stdout); + for (const key of ["claude_code", "codex", "gemini", "kimi_code", "omp", "agy"]) expect(json.tools[key].total_sessions).toBe(0); + expect(json.tools.agy.unattributed).toBe(true); + expect(json.total_sessions).toBe(0); + const { stdout, stderr } = discover("summary"); + expect(stdout).toContain("Kimi Code: 0"); + expect(stdout).toContain("OMP: 0"); + expect(stdout).toContain("Antigravity: 0)"); + expect(stderr).toContain("Kimi Code: 0"); + }); + + test("Kimi Code sessions count per repo by workDir, inside the window only", () => { + const dir = repo("work/kimi-app", "https://github.com/acme/kimi-app.git"); + kimi("s-new", dir, Date.now()); + kimi("s-old", dir, old.getTime()); + const json = JSON.parse(discover().stdout); + expect(json.tools.kimi_code).toEqual({ label: "Kimi Code", total_sessions: 1, repos: 1 }); + const r = json.repos.find((x: any) => x.remote === "https://github.com/acme/kimi-app"); + expect(r.sessions).toEqual({ claude_code: 0, codex: 0, gemini: 0, kimi_code: 1, omp: 0 }); + }); + + test("OMP sessions count by the cwd in the file and its mtime; a session created before the window but written in it counts; logs never count", () => { + const dir = repo("work/omp-app", "https://github.com/acme/omp-app.git"); + omp("2026-01-01T00-00-00-000Z_aaaa.jsonl", dir); + omp("2026-10-01T00-00-00-000Z_bbbb.jsonl", dir, old); + mkdirSync(join(home, ".omp", "logs"), { recursive: true }); + writeFileSync(join(home, ".omp", "logs", "launch.log"), "proxy debug\n"); + const json = JSON.parse(discover().stdout); + expect(json.tools.omp.total_sessions).toBe(1); + expect(json.repos.find((x: any) => x.remote === "https://github.com/acme/omp-app").sessions.omp).toBe(1); + }); + + test("Antigravity sessions count by the later of the .db and -wal mtimes, in total_sessions but in no repo", () => { + agy("a.db"); + agy("b.db", old, new Date()); + agy("c.db", old); + agy("notes.txt"); + const json = JSON.parse(discover().stdout); + expect(json.tools.agy).toEqual({ label: "Antigravity", total_sessions: 2, repos: 0, unattributed: true }); + expect(json.total_sessions).toBe(2); + expect(json.repos).toEqual([]); + const summary = discover("summary").stdout; + expect(summary).toContain("Sessions: 2 total (CC: 0, Codex: 0, Gemini: 0, Kimi Code: 0, OMP: 0, Antigravity: 2)"); + expect(summary).toContain("Antigravity sessions are not attributed to a repo"); + }); + }); + describe("extractCwdFromJsonl reads whole lines up to 1 MiB (PR #1169 bug #8, #1315)", () => { // Regression: the old 8KB cap landed mid-line on Claude Code sessions with // long headers, JSON.parse threw on the truncated tail, the catch