v1.92.20.0 /retro global counts Kimi Code, OMP and Antigravity sessions (#3740)

* global-discover: scanner registry; Kimi Code, OMP and Antigravity sessions (#2917, #1977)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: /retro global lists Kimi Code, OMP and Antigravity; TODOS marks #2917 shipped

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 08:12:16 +00:00
1 parent dcc1c51bfc
commit fd5fcb4318
10 files changed
+305 -68

No files matched your search

+14
View File
@@ -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.
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.92.19.0
1.92.20.0
+1 -1
View File
@@ -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,
+167 -42
View File
@@ -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<CodexOriginator, number>;
type ToolId = "claude_code" | "codex" | "gemini" | "kimi_code" | "omp";
type ToolCounts = Record<ToolId, number>;
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<ToolId | "agy", ToolSummary>;
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 <timestamp>_<uuid>.jsonl per session under ~/.omp/agent/sessions/<workspace>/.
* 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<Repo[]> {
@@ -555,7 +685,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
}
}
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<Repo[]> {
}
// 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<ToolId, ToolSummary>;
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(", ")}`);
}
+1 -1
View File
@@ -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",
+7 -8
View File
@@ -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
},
+7 -8
View File
@@ -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
},
+105 -5
View File
@@ -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<string, { total_sessions: number }>).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