gramatr 0.3.62 → 0.3.64

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.
package/bin/gmtr-login.ts CHANGED
@@ -365,10 +365,26 @@ async function loginBrowser(): Promise<void> {
365
365
  console.log(' Sign in with Google or GitHub, approve the device, then return here.\n');
366
366
  console.log(' Waiting for authorization...');
367
367
 
368
- const accessToken = await Promise.race([
369
- pollDeviceAuthorization(device.device_code),
370
- new Promise<string>((_, reject) => setTimeout(() => reject(new Error('Device login timed out')), device.expires_in * 1000)),
371
- ]);
368
+ // v0.3.63 hotfix: must clear the timeout after the race resolves,
369
+ // otherwise the orphan setTimeout keeps the Node event loop alive
370
+ // until expires_in elapses (typically 600s). Symptom: success path
371
+ // prints "Authenticated successfully" and then hangs until Ctrl+C.
372
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
373
+ const timeoutPromise = new Promise<string>((_, reject) => {
374
+ timeoutHandle = setTimeout(
375
+ () => reject(new Error('Device login timed out')),
376
+ device.expires_in * 1000,
377
+ );
378
+ });
379
+ let accessToken: string;
380
+ try {
381
+ accessToken = await Promise.race([
382
+ pollDeviceAuthorization(device.device_code),
383
+ timeoutPromise,
384
+ ]);
385
+ } finally {
386
+ if (timeoutHandle) clearTimeout(timeoutHandle);
387
+ }
372
388
 
373
389
  const config = readConfig();
374
390
  config.token = accessToken;
package/bin/gramatr.ts CHANGED
@@ -8,7 +8,7 @@ import { dirname, join } from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { detectTargets, findTarget, summarizeDetectedLocalTargets, type IntegrationTargetId } from '../core/targets.ts';
10
10
  import { VERSION } from '../core/version.ts';
11
- import { findStaleArtifacts, runLegacyMigration } from '../core/migration.ts';
11
+ import { findStaleArtifacts, runDeepClean, runLegacyMigration } from '../core/migration.ts';
12
12
  import {
13
13
  formatDetectionLines,
14
14
  formatDoctorLines,
@@ -203,7 +203,7 @@ function installTarget(targetId: IntegrationTargetId): void {
203
203
  }
204
204
  }
205
205
 
206
- function migrate(apply: boolean): void {
206
+ function migrate(apply: boolean, deep: boolean = false): void {
207
207
  const homeDir = homedir();
208
208
  const clientDir = process.env.GMTR_DIR || join(homeDir, 'gmtr-client');
209
209
  runLegacyMigration({
@@ -213,6 +213,10 @@ function migrate(apply: boolean): void {
213
213
  apply,
214
214
  log,
215
215
  });
216
+ if (deep) {
217
+ log('');
218
+ runDeepClean({ homeDir, apply, log });
219
+ }
216
220
  }
217
221
 
218
222
  function doctor(): void {
@@ -321,9 +325,13 @@ function main(): void {
321
325
  case 'doctor':
322
326
  doctor();
323
327
  return;
324
- case 'migrate':
325
- migrate(targetArg === '--apply');
328
+ case 'migrate': {
329
+ const flags = positionals.slice(1);
330
+ const apply = flags.includes('--apply');
331
+ const deep = flags.includes('--deep');
332
+ migrate(apply, deep);
326
333
  return;
334
+ }
327
335
  case 'upgrade':
328
336
  upgrade();
329
337
  return;
@@ -341,7 +349,7 @@ function main(): void {
341
349
  log(' detect Show detected CLI platforms');
342
350
  log(' doctor Check installation health');
343
351
  log(' upgrade Upgrade all installed targets');
344
- log(' migrate [--apply] Clean up legacy artifacts');
352
+ log(' migrate [--apply] [--deep] Clean up legacy artifacts (--deep also removes PAI/Fabric/aios)');
345
353
  log(' help Show this help');
346
354
  log('');
347
355
  log('Examples:');
@@ -0,0 +1,54 @@
1
+ /**
2
+ * gramatr client config resolver (statusline refactor, #495).
3
+ *
4
+ * Single-purpose helper that returns the server URL + auth token with
5
+ * NO reads of ~/.claude* or ~/gmtr-client/settings.json. This is the
6
+ * statusline-specific config path; the richer installer auth lives in
7
+ * core/auth.ts for a reason — this file must stay small and side-effect-free
8
+ * so it can be loaded in a 2s shim without touching disk more than once.
9
+ *
10
+ * Resolution:
11
+ * URL — GMTR_URL → ~/.gmtr.json.url → https://api.gramatr.com
12
+ * Token — GMTR_TOKEN → AIOS_MCP_TOKEN → ~/.gmtr.json.token → null
13
+ */
14
+ import { existsSync, readFileSync } from 'fs';
15
+ import { homedir } from 'os';
16
+ import { join } from 'path';
17
+
18
+ export interface GramatrConfig {
19
+ url: string;
20
+ token: string | null;
21
+ }
22
+
23
+ const DEFAULT_URL = 'https://api.gramatr.com';
24
+
25
+ function getHome(): string {
26
+ return process.env.HOME || process.env.USERPROFILE || homedir();
27
+ }
28
+
29
+ function readGmtrJson(): Record<string, any> | null {
30
+ const path = join(getHome(), '.gmtr.json');
31
+ if (!existsSync(path)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(path, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function getGramatrConfig(): GramatrConfig {
40
+ const json = readGmtrJson();
41
+
42
+ const url =
43
+ process.env.GMTR_URL ||
44
+ (typeof json?.url === 'string' && json.url) ||
45
+ DEFAULT_URL;
46
+
47
+ const token =
48
+ process.env.GMTR_TOKEN ||
49
+ process.env.AIOS_MCP_TOKEN ||
50
+ (typeof json?.token === 'string' && json.token) ||
51
+ null;
52
+
53
+ return { url: url as string, token };
54
+ }
package/bin/lib/git.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Git state collection for the statusline client (#495).
3
+ *
4
+ * Three subprocess calls, not seven:
5
+ * 1. git rev-parse --show-toplevel — is this even a repo?
6
+ * 2. git remote get-url origin — project_id derivation
7
+ * 3. git status --porcelain=v2 --branch — branch + file counts in one call
8
+ *
9
+ * A fourth call (git log -1 --format=%cr) runs only when the porcelain output
10
+ * indicates we have commits, keeping the empty-repo path clean.
11
+ *
12
+ * All functions are pure in terms of parseable input → output (parsePorcelain,
13
+ * parseProjectId) so they can be tested without spawning git.
14
+ */
15
+ import { execSync } from 'child_process';
16
+
17
+ export interface GitStateFields {
18
+ branch: string;
19
+ ahead: number;
20
+ behind: number;
21
+ modified: number;
22
+ untracked: number;
23
+ stash: number;
24
+ last_commit_age: string;
25
+ }
26
+
27
+ export interface GitInfo {
28
+ projectId: string;
29
+ state: GitStateFields;
30
+ }
31
+
32
+ export function parseProjectId(remoteUrl: string): string {
33
+ if (!remoteUrl) return '';
34
+ // Handle both https://github.com/org/repo(.git) and git@github.com:org/repo(.git)
35
+ const cleaned = remoteUrl.trim().replace(/\.git$/, '');
36
+ const sshMatch = cleaned.match(/[:/]([^:/]+\/[^:/]+)$/);
37
+ if (sshMatch) return sshMatch[1];
38
+ return cleaned;
39
+ }
40
+
41
+ /**
42
+ * Parse `git status --porcelain=v2 --branch` output.
43
+ *
44
+ * Header lines start with '#'. Entry lines start with '1', '2', 'u', or '?'.
45
+ * We count:
46
+ * branch from `# branch.head <name>`
47
+ * ahead/behind from `# branch.ab +N -M`
48
+ * modified from '1 ', '2 ', 'u ' entries
49
+ * untracked from '? ' entries
50
+ */
51
+ export function parsePorcelain(out: string): GitStateFields {
52
+ const state: GitStateFields = {
53
+ branch: '',
54
+ ahead: 0,
55
+ behind: 0,
56
+ modified: 0,
57
+ untracked: 0,
58
+ stash: 0,
59
+ last_commit_age: '',
60
+ };
61
+
62
+ for (const line of out.split('\n')) {
63
+ if (!line) continue;
64
+ if (line.startsWith('# branch.head ')) {
65
+ state.branch = line.slice('# branch.head '.length).trim();
66
+ } else if (line.startsWith('# branch.ab ')) {
67
+ const parts = line.slice('# branch.ab '.length).trim().split(/\s+/);
68
+ for (const p of parts) {
69
+ if (p.startsWith('+')) state.ahead = parseInt(p.slice(1), 10) || 0;
70
+ else if (p.startsWith('-')) state.behind = parseInt(p.slice(1), 10) || 0;
71
+ }
72
+ } else if (line[0] === '1' || line[0] === '2' || line[0] === 'u') {
73
+ state.modified += 1;
74
+ } else if (line[0] === '?') {
75
+ state.untracked += 1;
76
+ }
77
+ }
78
+
79
+ return state;
80
+ }
81
+
82
+ function execOrNull(cmd: string, cwd: string): string | null {
83
+ try {
84
+ return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function getGitState(cwd: string): GitInfo | null {
91
+ const root = execOrNull('git rev-parse --show-toplevel', cwd);
92
+ if (!root) return null;
93
+
94
+ const remote = execOrNull('git remote get-url origin', cwd) ?? '';
95
+ const porcelain = execOrNull('git status --porcelain=v2 --branch', cwd) ?? '';
96
+ const state = parsePorcelain(porcelain);
97
+
98
+ // Stash count — cheap single call; returns empty on no stashes
99
+ const stashRaw = execOrNull('git stash list', cwd) ?? '';
100
+ state.stash = stashRaw ? stashRaw.split('\n').filter(Boolean).length : 0;
101
+
102
+ // Last commit age — only meaningful if branch has commits
103
+ if (state.branch && state.branch !== '(detached)') {
104
+ const age = execOrNull('git log -1 --format=%cr', cwd);
105
+ if (age) state.last_commit_age = age.trim();
106
+ }
107
+
108
+ const projectId = parseProjectId(remote) || 'local';
109
+
110
+ return { projectId, state };
111
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Minimal stdin reader for the statusline shim (#495).
3
+ *
4
+ * Claude Code pipes a JSON blob to statusline commands. We read it with a
5
+ * hard cap + timeout so a broken pipe never hangs the render.
6
+ */
7
+
8
+ export interface CCInput {
9
+ session_id?: string;
10
+ cwd?: string;
11
+ workspace?: { current_dir?: string };
12
+ }
13
+
14
+ const MAX_STDIN_BYTES = 64 * 1024;
15
+ const STDIN_TIMEOUT_MS = 500;
16
+
17
+ export async function readStdin(): Promise<CCInput> {
18
+ // If stdin is a TTY there's nothing to read — return empty immediately.
19
+ if (process.stdin.isTTY) return {};
20
+
21
+ return new Promise((resolve) => {
22
+ let data = '';
23
+ let settled = false;
24
+
25
+ const finish = (input: CCInput) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ resolve(input);
29
+ };
30
+
31
+ const timer = setTimeout(() => finish({}), STDIN_TIMEOUT_MS);
32
+
33
+ process.stdin.setEncoding('utf8');
34
+ process.stdin.on('data', (chunk: string) => {
35
+ data += chunk;
36
+ if (data.length > MAX_STDIN_BYTES) {
37
+ data = data.slice(0, MAX_STDIN_BYTES);
38
+ }
39
+ });
40
+ process.stdin.on('end', () => {
41
+ clearTimeout(timer);
42
+ try {
43
+ finish(data.trim() ? (JSON.parse(data) as CCInput) : {});
44
+ } catch {
45
+ finish({});
46
+ }
47
+ });
48
+ process.stdin.on('error', () => {
49
+ clearTimeout(timer);
50
+ finish({});
51
+ });
52
+ });
53
+ }
package/bin/statusline.ts CHANGED
@@ -1,437 +1,81 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * gramatr Status LineTypeScript rewrite of statusline.sh (#158)
3
+ * gramatr status linethin client shim (#495).
4
4
  *
5
- * Single source of truth: gramatr API for server metrics, local for git/env.
6
- * Replaces 1,383-line bash script with type-safe, testable TypeScript.
5
+ * The server owns composition and rendering. This file is a pipe:
6
+ * 1. Read stdin (Claude Code JSON — session_id, cwd)
7
+ * 2. Collect git state (3 subprocess calls, see lib/git.ts)
8
+ * 3. POST /api/v1/statusline/render with render_as=native
9
+ * 4. Write the server's rendered string to stdout
7
10
  *
8
- * Data sources:
9
- * API → entity count, token savings, classifier stats, learning signals, skills, version
10
- * Local git status, context window, Anthropic usage, session label, location/weather (cached)
11
- * Stdin Claude Code JSON (context_window, session_id, model, workspace)
11
+ * Anti-criteria (enforced by regression grep in CI — see
12
+ * lib/__tests__/statusline-anti-criteria.test.ts for the tombstone list):
13
+ * - No temp-file reads or writes
14
+ * - No Claude config reads
15
+ * - No legacy client settings reads
16
+ * - No display of Claude-owned data (context window, model name)
17
+ * - No local caches (statusline is a view, not a database)
18
+ * - One and only one network call
19
+ *
20
+ * Failure modes are FIRST-CLASS:
21
+ * - not a repo → silent (exit 0, nothing written)
22
+ * - no token → authentication prompt
23
+ * - offline/timeout/non-2xx → offline indicator
12
24
  */
13
25
 
14
- import { readFileSync, existsSync, writeFileSync, statSync } from 'fs';
15
- import { execSync } from 'child_process';
16
- import { join } from 'path';
17
-
18
- // ── Types ──────────────────────────────────────────────────────────────────
19
-
20
- interface CCInput {
21
- workspace?: { current_dir?: string };
22
- cwd?: string;
23
- session_id?: string;
24
- model?: { display_name?: string };
25
- version?: string;
26
- cost?: { total_duration_ms?: number };
27
- context_window?: {
28
- context_window_size?: number;
29
- used_percentage?: number;
30
- remaining_percentage?: number;
31
- total_input_tokens?: number;
32
- total_output_tokens?: number;
33
- };
34
- }
26
+ import { getGitState } from './lib/git.js';
27
+ import { getGramatrConfig } from './lib/config.js';
28
+ import { readStdin } from './lib/stdin.js';
35
29
 
36
- interface GramatrStats {
37
- server_version: string;
38
- entity_count: number;
39
- observation_count: number;
40
- search_count: number;
41
- tokens_saved_total: number;
42
- tokens_saved_7d: number;
43
- classifications_total: number;
44
- classifications_7d: number;
45
- operations_1h: number;
46
- operations_24h: number;
47
- classifier: {
48
- level: number;
49
- model: string;
50
- accuracy: number;
51
- feedback_rate: number;
52
- total_classifications: number;
53
- };
54
- learning: {
55
- latest: number | null;
56
- avg_1d: number | null;
57
- avg_1w: number | null;
58
- avg_1mo: number | null;
59
- count: number;
60
- };
61
- skills_count: number;
62
- }
30
+ const CLIENT_TIMEOUT_MS = 2000;
63
31
 
64
- interface GitInfo {
65
- projectId: string; // org/repo from remote, fallback to directory name
66
- branch: string;
67
- stashCount: number;
68
- modified: number;
69
- untracked: number;
70
- ahead: number;
71
- behind: number;
72
- lastCommitAge: string;
73
- lastCommitColor: string;
74
- }
32
+ type Size = 'small' | 'medium' | 'large';
75
33
 
34
+ async function main(): Promise<void> {
35
+ const input = await readStdin();
36
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
76
37
 
77
-
78
- // ── Config ─────────────────────────────────────────────────────────────────
79
-
80
- const HOME = process.env.HOME || '';
81
- const GMTR_DIR = process.env.GMTR_DIR || join(HOME, 'gmtr-client');
82
- const STATE_DIR = join(GMTR_DIR, '.state');
83
- const SETTINGS_PATH = join(HOME, '.claude', 'settings.json');
84
-
85
- function getGramatrUrl(): string {
86
- try {
87
- const claude = JSON.parse(readFileSync(join(HOME, '.claude.json'), 'utf8'));
88
- return claude?.mcpServers?.gramatr?.url?.replace('/mcp', '') || 'https://api.gramatr.com';
89
- } catch {
90
- return 'https://api.gramatr.com';
38
+ const git = getGitState(cwd);
39
+ if (!git) {
40
+ // Not inside a git repo — render nothing, exit clean.
41
+ return;
91
42
  }
92
- }
93
-
94
- function getAuthToken(): string {
95
- // Check settings.json first, then env var
96
- try {
97
- const settings = JSON.parse(readFileSync(join(GMTR_DIR, 'settings.json'), 'utf8'));
98
- if (settings?.auth?.token) return settings.auth.token;
99
- } catch { /* no settings file */ }
100
- return process.env.GMTR_TOKEN || process.env.AIOS_MCP_TOKEN || '';
101
- }
102
43
 
103
- // ── Cache Helper ───────────────────────────────────────────────────────────
104
-
105
- function readCache<T>(path: string, ttlMs: number): T | null {
106
- try {
107
- if (!existsSync(path)) return null;
108
- const stat = statSync(path);
109
- if (Date.now() - stat.mtimeMs > ttlMs) return null;
110
- return JSON.parse(readFileSync(path, 'utf8'));
111
- } catch {
112
- return null;
44
+ const { url, token } = getGramatrConfig();
45
+ if (!token) {
46
+ process.stdout.write(' ● grāmatr not authenticated run npx gramatr login\n');
47
+ return;
113
48
  }
114
- }
115
-
116
- function writeCache(path: string, data: unknown): void {
117
- try {
118
- writeFileSync(path, JSON.stringify(data));
119
- } catch { /* non-critical */ }
120
- }
121
-
122
- // ── Colors (24-bit ANSI) ───────────────────────────────────────────────────
123
-
124
- // Gray Matter palette — from design-tokens.json (locked 2026-03-28)
125
- const c = {
126
- reset: '\x1b[0m',
127
- bold: '\x1b[1m',
128
- dim: '\x1b[2m',
129
- // Text hierarchy (warm neutrals)
130
- text: '\x1b[38;2;236;236;236m', // #ECECEC — primary text
131
- textSec: '\x1b[38;2;136;136;136m', // #888888 — secondary
132
- textMuted: '\x1b[38;2;192;192;192m', // #C0C0C0 — muted/labels (dimmer than primary, readable on light backgrounds)
133
- // Brand
134
- primary: '\x1b[38;2;59;130;246m', // #3B82F6 — blue accent
135
- accent: '\x1b[38;2;96;165;250m', // #60A5FA — lighter blue
136
- // Structural
137
- border: '\x1b[38;2;51;51;51m', // #333333 — separators
138
- surface: '\x1b[38;2;26;26;26m', // #1A1A1A — surface
139
- // Semantic
140
- success: '\x1b[38;2;74;222;128m', // #4ADE80
141
- warning: '\x1b[38;2;250;204;21m', // #FACC15
142
- error: '\x1b[38;2;248;113;113m', // #F87171
143
- // Bg
144
- barBg: '\x1b[48;2;26;26;26m', // #1A1A1A — context bar empty
145
- };
146
-
147
- function ageColor(age: string): string {
148
- if (age.endsWith('m') && parseInt(age) < 30) return c.success;
149
- if (age.endsWith('h') && parseInt(age) < 2) return c.accent;
150
- if (age.endsWith('d') && parseInt(age) < 2) return c.warning;
151
- return c.error;
152
- }
153
49
 
154
- // ── Data Fetchers ──────────────────────────────────────────────────────────
155
-
156
- async function fetchGramatrStats(): Promise<GramatrStats | null> {
157
- const cached = readCache<GramatrStats>(join(STATE_DIR, 'api-stats-cache.json'), 60_000);
158
- if (cached) return cached;
159
-
160
- const url = getGramatrUrl();
161
- const token = getAuthToken();
162
- if (!token) return null;
50
+ const envSize = process.env.GMTR_STATUSLINE_SIZE;
51
+ const size: Size =
52
+ envSize === 'small' || envSize === 'medium' || envSize === 'large' ? envSize : 'medium';
163
53
 
164
54
  try {
165
- const resp = await fetch(`${url}/api/v1/stats/statusline`, {
166
- headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
167
- signal: AbortSignal.timeout(5000),
168
- });
169
- if (!resp.ok) {
170
- // Fallback: read from local files if API not available yet
171
- return readLocalStats();
172
- }
173
- const data = await resp.json() as GramatrStats;
174
- writeCache(join(STATE_DIR, 'api-stats-cache.json'), data);
175
- return data;
176
- } catch {
177
- // API unavailable — fall back to local files
178
- return readLocalStats();
179
- }
180
- }
181
-
182
- function readLocalStats(): GramatrStats | null {
183
- // Backward-compatible: read from /tmp/ files (same as bash version)
184
- try {
185
- const stats = existsSync('/tmp/gmtr-stats.json')
186
- ? JSON.parse(readFileSync('/tmp/gmtr-stats.json', 'utf8'))
187
- : {};
188
- const savings = existsSync('/tmp/gmtr-classification-savings.json')
189
- ? JSON.parse(readFileSync('/tmp/gmtr-classification-savings.json', 'utf8'))
190
- : {};
191
-
192
- // Sum tokens from history
193
- let totalTokensSaved = 0;
194
- let ops1h = 0;
195
- let ops24h = 0;
196
- const now = Date.now();
197
- if (existsSync('/tmp/gmtr-op-history.jsonl')) {
198
- const lines = readFileSync('/tmp/gmtr-op-history.jsonl', 'utf8').trim().split('\n');
199
- for (const line of lines) {
200
- try {
201
- const entry = JSON.parse(line);
202
- totalTokensSaved += entry.tokens_saved || 0;
203
- const age = now - (entry.timestamp || 0);
204
- if (age < 3600_000) ops1h++;
205
- if (age < 86400_000) ops24h++;
206
- } catch { /* skip bad lines */ }
207
- }
208
- }
209
-
210
- return {
211
- server_version: savings.server_version || '',
212
- entity_count: stats.entity_count || 0,
213
- observation_count: stats.observation_count || 0,
214
- search_count: stats.search_count || 0,
215
- tokens_saved_total: 0,
216
- tokens_saved_7d: 0,
217
- classifications_total: 0,
218
- classifications_7d: 0,
219
- operations_1h: ops1h,
220
- operations_24h: ops24h,
221
- classifier: {
222
- level: stats.classifier_level || 0,
223
- model: stats.classifier_model || savings.classifier_model || '', // previously: qwen_model
224
- accuracy: stats.accuracy || 0,
225
- feedback_rate: stats.feedback_rate || 0,
226
- total_classifications: stats.total_classifications || 0,
55
+ const resp = await fetch(`${url}/api/v1/statusline/render`, {
56
+ method: 'POST',
57
+ headers: {
58
+ Authorization: `Bearer ${token}`,
59
+ 'Content-Type': 'application/json',
227
60
  },
228
- learning: { latest: null, avg_1d: null, avg_1w: null, avg_1mo: null, count: 0 },
229
- skills_count: 0,
230
- };
231
- } catch {
232
- return null;
233
- }
234
- }
235
-
236
- function getGitInfo(cwd: string): GitInfo | null {
237
- try {
238
- const run = (cmd: string) => execSync(cmd, { cwd, timeout: 3000, encoding: 'utf8' }).trim();
239
-
240
- // Check if git repo
241
- try { run('git rev-parse --git-dir'); } catch { return null; }
242
-
243
- // Resolve project ID: git remote → org/repo, fallback to directory name
244
- let projectId = run('git rev-parse --show-toplevel').split('/').pop() || '';
245
- try {
246
- const remote = run('git remote get-url origin');
247
- const match = remote.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
248
- if (match) projectId = `${match[1]}/${match[2]}`;
249
- } catch { /* no remote — use directory name */ }
250
- const branch = run('git branch --show-current') || 'detached';
251
-
252
- let stashCount = 0;
253
- try { stashCount = run('git stash list').split('\n').filter(Boolean).length; } catch {}
254
-
255
- let modified = 0, untracked = 0;
256
- try {
257
- const status = run('git status --porcelain');
258
- for (const line of status.split('\n').filter(Boolean)) {
259
- if (line.startsWith('??')) untracked++;
260
- else modified++;
261
- }
262
- } catch {}
263
-
264
- let ahead = 0, behind = 0;
265
- try {
266
- const counts = run('git rev-list --left-right --count HEAD...@{u}');
267
- const [a, b] = counts.split('\t').map(Number);
268
- ahead = a || 0;
269
- behind = b || 0;
270
- } catch {}
271
-
272
- let lastCommitAge = '?';
273
- let lastCommitColor = c.slate400;
274
- try {
275
- const epoch = parseInt(run('git log -1 --format=%ct'));
276
- const seconds = Math.floor(Date.now() / 1000) - epoch;
277
- if (seconds < 3600) {
278
- lastCommitAge = `${Math.floor(seconds / 60)}m`;
279
- } else if (seconds < 86400) {
280
- lastCommitAge = `${Math.floor(seconds / 3600)}h`;
281
- } else {
282
- lastCommitAge = `${Math.floor(seconds / 86400)}d`;
283
- }
284
- lastCommitColor = ageColor(lastCommitAge);
285
- } catch {}
61
+ body: JSON.stringify({
62
+ project_id: git.projectId,
63
+ session_id: input.session_id || 'no-session',
64
+ size,
65
+ render_as: 'native',
66
+ git_state: git.state,
67
+ }),
68
+ signal: AbortSignal.timeout(CLIENT_TIMEOUT_MS),
69
+ });
286
70
 
287
- return { projectId, branch, stashCount, modified, untracked, ahead, behind, lastCommitAge, lastCommitColor };
71
+ if (!resp.ok) throw new Error(`status ${resp.status}`);
72
+ const data = (await resp.json()) as { rendered?: string };
73
+ process.stdout.write(data.rendered ?? ' ● grāmatr │ offline\n');
288
74
  } catch {
289
- return null;
75
+ process.stdout.write(' ● grāmatr │ offline\n');
290
76
  }
291
77
  }
292
78
 
293
- // ── Rendering ──────────────────────────────────────────────────────────────
294
-
295
- function formatNumber(n: number): string {
296
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
297
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
298
- return String(n);
299
- }
300
-
301
- function contextBar(pct: number, width: number): string {
302
- const filled = Math.round((pct / 100) * width);
303
- let bar = '';
304
- for (let i = 0; i < width; i++) {
305
- if (i < filled) {
306
- const ratio = i / width;
307
- if (ratio < 0.4) bar += `${c.success}⛁`;
308
- else if (ratio < 0.6) bar += `${c.accent}⛁`;
309
- else if (ratio < 0.8) bar += `${c.warning}⛁`;
310
- else bar += `${c.error}⛁`;
311
- } else {
312
- bar += `${c.barBg} ${c.reset}`;
313
- }
314
- }
315
- return bar + c.reset;
316
- }
317
-
318
- function separator(width: number): string {
319
- return ` ${c.border}${'─'.repeat(Math.min(width - 4, 72))}${c.reset}`;
320
- }
321
-
322
- interface Heartbeat {
323
- effort: string;
324
- intent: string;
325
- confidence: string;
326
- totalMs: number;
327
- memoryDelivered: number;
328
- stale: boolean;
329
- }
330
-
331
- function getHeartbeat(): Heartbeat {
332
- try {
333
- const data = JSON.parse(readFileSync('/tmp/gmtr-classification-savings.json', 'utf8'));
334
- const age = Date.now() - (data.timestamp || 0);
335
- const stale = age > 300_000;
336
- const st = data.stage_timing || {};
337
- return {
338
- effort: data.effort || '',
339
- intent: data.intent || '',
340
- confidence: data.confidence ? `${Math.round(data.confidence * 100)}%` : '',
341
- totalMs: st.total_ms || data.classifier_time_ms || 0, // previously: qwen_time_ms
342
- memoryDelivered: data.memory_delivered || 0,
343
- stale,
344
- };
345
- } catch {
346
- return { effort: '', intent: '', confidence: '', totalMs: 0, memoryDelivered: 0, stale: true };
347
- }
348
- }
349
-
350
- function render(input: CCInput, gmtr: GramatrStats | null, git: GitInfo | null): string {
351
- const lines: string[] = [];
352
- const termWidth = parseInt(process.env.COLUMNS || '80', 10);
353
- const barWidth = Math.min(termWidth - 20, 55);
354
- const version = gmtr?.server_version ? `v${gmtr.server_version}` : '';
355
- const statusIcon = gmtr ? `${c.success}●${c.reset}` : `${c.error}○${c.reset}`;
356
- const hb = getHeartbeat();
357
-
358
- // ── LINE 1: BRAND + HEARTBEAT ──
359
- let heartbeatText = '';
360
- if (!hb.stale && hb.effort) {
361
- heartbeatText = `${c.text}${hb.effort}/${hb.intent}${c.reset} ${c.accent}${hb.confidence}${c.reset} ${c.textMuted}${hb.totalMs}ms${c.reset}`;
362
- if (hb.memoryDelivered > 0) {
363
- heartbeatText += ` ${c.success}◇${hb.memoryDelivered} memories${c.reset}`;
364
- }
365
- }
366
- const brandLine = ` ${c.border}──${c.reset} ${statusIcon} ` +
367
- `${c.primary}grāmatr${c.reset} ` +
368
- `${c.textMuted}${version}${c.reset}` +
369
- (heartbeatText ? ` ${c.border}│${c.reset} ${heartbeatText}` : '') +
370
- ` ${c.border}│${c.reset} ${c.textMuted}gramatr.com${c.reset}`;
371
- lines.push(brandLine);
372
-
373
- // ── LINE 2: CONTEXT ──
374
- const ctxPct = input.context_window?.used_percentage || 0;
375
- lines.push(separator(termWidth));
376
- lines.push(` ${c.accent}◉${c.reset} ${c.textMuted}CONTEXT:${c.reset} ${contextBar(ctxPct, barWidth)} ${c.text}${Math.round(ctxPct)}%${c.reset}`);
377
-
378
- // ── LINE 3: PROJECT ──
379
- if (git) {
380
- lines.push(separator(termWidth));
381
- lines.push(
382
- ` ${c.primary}◈${c.reset} ${c.text}${git.projectId}${c.reset}` +
383
- ` ${c.border}│${c.reset} ${c.accent}${git.branch}${c.reset}`
384
- );
385
- }
386
-
387
- // ── LINE 5: GRAMATR INTELLIGENCE ──
388
- if (gmtr) {
389
- lines.push(separator(termWidth));
390
- const cl = gmtr.classifier;
391
- const level = cl.level > 0 ? `L${cl.level}` : '—';
392
- const savedWeek = formatNumber(gmtr.tokens_saved_7d || 0);
393
- const savedTotal = formatNumber(gmtr.tokens_saved_total || 0);
394
- lines.push(
395
- ` ${c.primary}◎${c.reset}` +
396
- ` ${c.accent}⬢${formatNumber(gmtr.entity_count)}${c.reset}${c.textMuted} entities${c.reset}` +
397
- ` ${c.border}│${c.reset} ${c.accent}◇${formatNumber(gmtr.observation_count)}${c.reset}${c.textMuted} obs${c.reset}` +
398
- ` ${c.border}│${c.reset} ${c.primary}${level}${c.reset}` +
399
- ` ${c.border}│${c.reset} ${c.textMuted}saved:${c.reset} ${c.success}${savedWeek}/wk${c.reset} ${c.text}${savedTotal}/all${c.reset}`
400
- );
401
- }
402
-
403
- lines.push(separator(termWidth));
404
- return lines.join('\n');
405
- }
406
-
407
- // ── Main ───────────────────────────────────────────────────────────────────
408
-
409
- async function main() {
410
- // Read CC JSON from stdin
411
- let input: CCInput = {};
412
- try {
413
- const chunks: Buffer[] = [];
414
- for await (const chunk of process.stdin) {
415
- chunks.push(Buffer.from(chunk));
416
- }
417
- const raw = Buffer.concat(chunks).toString('utf8');
418
- if (raw.trim()) input = JSON.parse(raw);
419
- } catch { /* no stdin or bad JSON */ }
420
-
421
- const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
422
-
423
- // Fetch gramatr stats
424
- const gmtr = await fetchGramatrStats();
425
-
426
- // Git is sync (fast, <100ms)
427
- const git = getGitInfo(cwd);
428
-
429
- // Render and output
430
- const output = render(input, gmtr, git);
431
- process.stdout.write(output + '\n');
432
- }
433
-
434
79
  main().catch(() => {
435
- // Never crash — output minimal fallback
436
- process.stdout.write(` gramatr │ status unavailable\n`);
80
+ process.stdout.write(' ● grāmatr error\n');
437
81
  });
@@ -34,6 +34,9 @@ async function main(): Promise<void> {
34
34
  projectId,
35
35
  sessionId: input.session_id,
36
36
  timeoutMs: 15000,
37
+ // #496 Phase 3: ask the server for a composed [GMTR Status] block.
38
+ includeStatusline: true,
39
+ statuslineSize: 'small',
37
40
  });
38
41
  const route = result.route as RouteResponse | null;
39
42
 
@@ -40,7 +40,19 @@ function formatMemoryContext(route: RouteResponse): string[] {
40
40
  }
41
41
 
42
42
  export function buildUserPromptAdditionalContext(route: RouteResponse): string {
43
- const lines = ['[GMTR Intelligence]'];
43
+ const lines: string[] = [];
44
+
45
+ // Cross-tool statusline injection (#496 Phase 3). When the server returns
46
+ // a composed markdown block, prepend it so Codex / Gemini surface gramatr
47
+ // status at the top of every turn. Claude Code's routing call leaves the
48
+ // include_statusline flag off (ISC-A1 of #496) because CC has a native
49
+ // terminal statusline and we must not double-inject.
50
+ if (typeof route.statusline_markdown === 'string' && route.statusline_markdown.trim()) {
51
+ lines.push(route.statusline_markdown.trim());
52
+ lines.push('');
53
+ }
54
+
55
+ lines.push('[GMTR Intelligence]');
44
56
  const classification = route.classification;
45
57
 
46
58
  if (classification) {
package/core/migration.ts CHANGED
@@ -1,7 +1,242 @@
1
- import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { buildClaudeHooksFile } from './install.ts';
4
4
 
5
+ // ── Deep clean: PAI / Fabric / aios artifact removal ─────────────────────────
6
+ //
7
+ // `gramatr migrate --deep` detects and removes PAI, Fabric, and aios
8
+ // leftovers from ~/.claude/{commands,skills} and from MCP server registries.
9
+ // Always dry-run unless --apply. Always backs up to ~/.claude/backups/
10
+ // before deleting. Conservative: deny-list only, never blanket-deletes.
11
+
12
+ /**
13
+ * Slash command + skill name patterns that identify PAI/Fabric/aios artifacts.
14
+ * Match is case-insensitive against the basename (without .md extension).
15
+ */
16
+ export const DEEP_CLEAN_DENY_PATTERNS: RegExp[] = [
17
+ /^pai[-_]/i,
18
+ /^pai$/i,
19
+ /^fabric[-_]/i,
20
+ /^fabric$/i,
21
+ /^aios[-_]/i,
22
+ /^aios$/i,
23
+ /^extract[-_]?wisdom/i,
24
+ /^extract[-_]/i,
25
+ /^analyze[-_]/i,
26
+ /^summarize[-_]/i,
27
+ /^create[-_]?prompt/i,
28
+ /^write[-_]?essay/i,
29
+ /^write[-_]?story/i,
30
+ /^get[-_]?wow/i,
31
+ /^improve[-_]?prompt/i,
32
+ /^official[-_]?pattern/i,
33
+ /^pattern[-_]/i,
34
+ /^council$/i,
35
+ /^prompting$/i,
36
+ /^becreative$/i,
37
+ /^beexpert$/i,
38
+ ];
39
+
40
+ /**
41
+ * MCP server keys that are PAI/Fabric/aios remnants (case-insensitive).
42
+ * The legacy migration already strips `aios`. This adds the rest.
43
+ */
44
+ export const DEEP_CLEAN_MCP_DENY_KEYS: RegExp[] = [
45
+ /^aios/i,
46
+ /^pai$/i,
47
+ /^pai[-_]/i,
48
+ /^fabric/i,
49
+ ];
50
+
51
+ /**
52
+ * Slash commands gramatr itself ships. Always preserved by deep-clean.
53
+ * Both legacy `gmtr-*` / `GMTR-*` and the new lowercase `gramatr-*` per #500.
54
+ */
55
+ export const GRAMATR_OWNED_COMMANDS: Set<string> = new Set([
56
+ 'gramatr-algorithm.md',
57
+ 'gramatr-init.md',
58
+ 'gramatr-login.md',
59
+ 'gramatr-query.md',
60
+ 'gramatr-recall.md',
61
+ 'gramatr-remember.md',
62
+ 'GMTR-ALGORITHM.md',
63
+ 'gmtr-init.md',
64
+ 'gmtr-login.md',
65
+ 'gmtr-query.md',
66
+ 'gmtr-recall.md',
67
+ 'gmtr-remember.md',
68
+ ]);
69
+
70
+ export interface DeepCleanArtifact {
71
+ kind: 'command-file' | 'skill-dir' | 'mcp-server-entry';
72
+ path: string;
73
+ reason: string;
74
+ // For mcp-server-entry: which file the entry lives in + the key.
75
+ mcpFile?: string;
76
+ mcpKey?: string;
77
+ }
78
+
79
+ export interface DeepCleanResult {
80
+ artifacts: DeepCleanArtifact[];
81
+ backupDir?: string;
82
+ }
83
+
84
+ function matchesDenyPattern(name: string): boolean {
85
+ const stem = name.replace(/\.md$/i, '');
86
+ return DEEP_CLEAN_DENY_PATTERNS.some((rx) => rx.test(stem));
87
+ }
88
+
89
+ function matchesMcpDenyKey(key: string): boolean {
90
+ return DEEP_CLEAN_MCP_DENY_KEYS.some((rx) => rx.test(key));
91
+ }
92
+
93
+ /**
94
+ * Detect PAI/Fabric/aios cruft. Read-only — never modifies the filesystem.
95
+ */
96
+ export function findDeepCleanArtifacts(homeDir: string): DeepCleanArtifact[] {
97
+ const out: DeepCleanArtifact[] = [];
98
+
99
+ // 1. ~/.claude/commands/*.md — flag any matching deny pattern OR (when not
100
+ // in the gramatr allowlist AND matching a known PAI prefix). User's own
101
+ // custom commands are NEVER touched unless they match a deny pattern.
102
+ const commandsDir = join(homeDir, '.claude', 'commands');
103
+ if (existsSync(commandsDir) && statSync(commandsDir).isDirectory()) {
104
+ for (const entry of readdirSync(commandsDir)) {
105
+ if (!entry.endsWith('.md')) continue;
106
+ if (GRAMATR_OWNED_COMMANDS.has(entry)) continue;
107
+ if (matchesDenyPattern(entry)) {
108
+ out.push({
109
+ kind: 'command-file',
110
+ path: join(commandsDir, entry),
111
+ reason: 'PAI/Fabric/aios slash command (matches deny pattern)',
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // 2. ~/.claude/skills/*/ — flag any directory matching deny pattern.
118
+ // gramatr ships zero skills today; anything matching the deny patterns
119
+ // is definitely cruft. We do NOT blanket-delete the whole skills/ dir.
120
+ const skillsDir = join(homeDir, '.claude', 'skills');
121
+ if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
122
+ for (const entry of readdirSync(skillsDir)) {
123
+ const path = join(skillsDir, entry);
124
+ if (!statSync(path).isDirectory()) continue;
125
+ if (matchesDenyPattern(entry)) {
126
+ out.push({
127
+ kind: 'skill-dir',
128
+ path,
129
+ reason: 'PAI/Fabric skill directory (matches deny pattern)',
130
+ });
131
+ }
132
+ }
133
+ }
134
+
135
+ // 3. ~/.claude.json mcpServers + ~/.claude/mcp.json — flag deny-key entries.
136
+ for (const mcpFile of [join(homeDir, '.claude.json'), join(homeDir, '.claude', 'mcp.json')]) {
137
+ if (!existsSync(mcpFile)) continue;
138
+ let parsed: JsonObject;
139
+ try {
140
+ parsed = JSON.parse(readFileSync(mcpFile, 'utf8')) as JsonObject;
141
+ } catch {
142
+ continue;
143
+ }
144
+ const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : null;
145
+ if (!servers) continue;
146
+ for (const key of Object.keys(servers)) {
147
+ if (matchesMcpDenyKey(key)) {
148
+ out.push({
149
+ kind: 'mcp-server-entry',
150
+ path: `${mcpFile}#mcpServers.${key}`,
151
+ reason: `PAI/Fabric/aios MCP server entry "${key}"`,
152
+ mcpFile,
153
+ mcpKey: key,
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Run a deep-clean. Dry-run unless apply=true. When applying, backs up every
164
+ * removed artifact to ~/.claude/backups/gramatr-deep-clean-<timestamp>/ before
165
+ * deletion (full reversibility).
166
+ */
167
+ export function runDeepClean(opts: {
168
+ homeDir: string;
169
+ apply: boolean;
170
+ log?: (message: string) => void;
171
+ }): DeepCleanResult {
172
+ const { homeDir, apply, log } = opts;
173
+ const artifacts = findDeepCleanArtifacts(homeDir);
174
+
175
+ log?.('gramatr deep clean (PAI / Fabric / aios)');
176
+ log?.(`Mode: ${apply ? 'apply' : 'dry-run'}`);
177
+ log?.(`Found ${artifacts.length} artifact(s)`);
178
+
179
+ if (artifacts.length === 0) {
180
+ log?.('Nothing to clean. Already gramatr-only.');
181
+ return { artifacts };
182
+ }
183
+
184
+ // Group + display
185
+ const byKind = new Map<string, DeepCleanArtifact[]>();
186
+ for (const a of artifacts) {
187
+ const arr = byKind.get(a.kind) ?? [];
188
+ arr.push(a);
189
+ byKind.set(a.kind, arr);
190
+ }
191
+ for (const [kind, list] of byKind) {
192
+ log?.(`\n${kind} (${list.length}):`);
193
+ for (const a of list) log?.(` ${a.path} — ${a.reason}`);
194
+ }
195
+
196
+ if (!apply) {
197
+ log?.('\nDry-run only. Re-run with --apply to remove these (with backup).');
198
+ return { artifacts };
199
+ }
200
+
201
+ // ─── APPLY MODE ───
202
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
203
+ const backupDir = join(homeDir, '.claude', 'backups', `gramatr-deep-clean-${ts}`);
204
+ mkdirSync(backupDir, { recursive: true });
205
+ log?.(`\nBacking up to ${backupDir}`);
206
+
207
+ for (const artifact of artifacts) {
208
+ if (artifact.kind === 'command-file') {
209
+ const dest = join(backupDir, 'commands', artifact.path.split('/').pop() ?? 'unknown');
210
+ mkdirSync(join(backupDir, 'commands'), { recursive: true });
211
+ cpSync(artifact.path, dest);
212
+ rmSync(artifact.path, { force: true });
213
+ log?.(` removed ${artifact.path}`);
214
+ } else if (artifact.kind === 'skill-dir') {
215
+ const dest = join(backupDir, 'skills', artifact.path.split('/').pop() ?? 'unknown');
216
+ mkdirSync(join(backupDir, 'skills'), { recursive: true });
217
+ cpSync(artifact.path, dest, { recursive: true });
218
+ rmSync(artifact.path, { recursive: true, force: true });
219
+ log?.(` removed ${artifact.path}`);
220
+ } else if (artifact.kind === 'mcp-server-entry') {
221
+ // Strip the entry from the JSON file. Backup the original file once.
222
+ if (!artifact.mcpFile || !artifact.mcpKey) continue;
223
+ const fileBackup = join(backupDir, artifact.mcpFile.split('/').pop() ?? 'mcp.json');
224
+ if (!existsSync(fileBackup)) cpSync(artifact.mcpFile, fileBackup);
225
+ const parsed = JSON.parse(readFileSync(artifact.mcpFile, 'utf8')) as JsonObject;
226
+ if (isRecord(parsed.mcpServers)) {
227
+ const next = { ...parsed.mcpServers };
228
+ delete next[artifact.mcpKey];
229
+ parsed.mcpServers = next;
230
+ writeFileSync(artifact.mcpFile, `${JSON.stringify(parsed, null, 2)}\n`);
231
+ }
232
+ log?.(` stripped ${artifact.path}`);
233
+ }
234
+ }
235
+
236
+ log?.(`\nDone. ${artifacts.length} artifact(s) removed. Backup: ${backupDir}`);
237
+ return { artifacts, backupDir };
238
+ }
239
+
5
240
  const MANAGED_EVENTS = [
6
241
  'PreToolUse',
7
242
  'PostToolUse',
package/core/routing.ts CHANGED
@@ -20,6 +20,14 @@ export async function routePrompt(options: {
20
20
  projectId?: string;
21
21
  sessionId?: string;
22
22
  timeoutMs?: number;
23
+ /**
24
+ * Cross-tool statusline injection (#496). When true, the server attaches a
25
+ * composed [GMTR Status] markdown block to the route response. Hooks for
26
+ * Codex and Gemini set this; the Claude Code path leaves it false because
27
+ * Claude Code has its own native terminal statusline.
28
+ */
29
+ includeStatusline?: boolean;
30
+ statuslineSize?: 'small' | 'medium' | 'large';
23
31
  }): Promise<{ route: RouteResponse | null; error: MctToolCallError | null }> {
24
32
  const result = await callMcpToolDetailed<RouteResponse>(
25
33
  'gmtr_route_request',
@@ -27,6 +35,8 @@ export async function routePrompt(options: {
27
35
  prompt: options.prompt,
28
36
  ...(options.projectId ? { project_id: options.projectId } : {}),
29
37
  ...(options.sessionId ? { session_id: options.sessionId } : {}),
38
+ ...(options.includeStatusline ? { include_statusline: true } : {}),
39
+ ...(options.statuslineSize ? { statusline_size: options.statuslineSize } : {}),
30
40
  },
31
41
  options.timeoutMs ?? 15000,
32
42
  );
package/core/types.ts CHANGED
@@ -64,6 +64,7 @@ export interface ContextPreLoadPlan {
64
64
 
65
65
  export interface RouteResponse {
66
66
  classification?: RouteClassification;
67
+ statusline_markdown?: string;
67
68
  capability_audit?: CapabilityAuditResult;
68
69
  phase_template?: PhaseTemplate;
69
70
  quality_gate_config?: QualityGateConfig;
@@ -34,6 +34,9 @@ async function main(): Promise<void> {
34
34
  projectId,
35
35
  sessionId: input.session_id,
36
36
  timeoutMs: 15000,
37
+ // #496 Phase 3: ask the server for a composed [GMTR Status] block.
38
+ includeStatusline: true,
39
+ statuslineSize: 'small',
37
40
  });
38
41
  const route = result.route as RouteResponse | null;
39
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gramatr",
3
- "version": "0.3.62",
3
+ "version": "0.3.64",
4
4
  "description": "grāmatr — context engineering layer for AI coding agents. Every prompt gets a pre-computed intelligence packet: decision routing, capability audit, behavioral directives, memory pre-load, and ISC scaffolds. Continuity across sessions for Claude Code, Codex, and Gemini CLI.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",