gramatr 0.3.63 → 0.3.65
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/gramatr.ts +13 -5
- package/bin/install.ts +11 -0
- package/bin/lib/config.ts +57 -0
- package/bin/lib/git.ts +111 -0
- package/bin/lib/stdin.ts +53 -0
- package/bin/statusline.ts +57 -413
- package/codex/hooks/user-prompt-submit.ts +3 -0
- package/core/formatting.ts +13 -1
- package/core/migration.ts +236 -1
- package/core/routing.ts +10 -0
- package/core/types.ts +1 -0
- package/gemini/hooks/user-prompt-submit.ts +3 -0
- package/package.json +1 -1
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
|
-
|
|
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:');
|
package/bin/install.ts
CHANGED
|
@@ -282,6 +282,14 @@ function installClientFiles(): void {
|
|
|
282
282
|
copyFileIfExists(join(SCRIPT_DIR, 'bin/statusline.ts'), join(CLIENT_DIR, 'bin/statusline.ts'), true);
|
|
283
283
|
copyFileIfExists(join(SCRIPT_DIR, 'bin/gmtr-login.ts'), join(CLIENT_DIR, 'bin/gmtr-login.ts'), true);
|
|
284
284
|
copyFileIfExists(join(SCRIPT_DIR, 'bin/render-claude-hooks.ts'), join(CLIENT_DIR, 'bin/render-claude-hooks.ts'), true);
|
|
285
|
+
|
|
286
|
+
// Bin lib helpers (statusline shim depends on these — see #495)
|
|
287
|
+
// Copies git.ts, config.ts, stdin.ts and any future helpers without enumerating each file.
|
|
288
|
+
const binLibSrc = join(SCRIPT_DIR, 'bin', 'lib');
|
|
289
|
+
if (existsSync(binLibSrc)) {
|
|
290
|
+
copyDir(binLibSrc, join(CLIENT_DIR, 'bin', 'lib'));
|
|
291
|
+
log('OK Installed bin/lib (statusline shim helpers)');
|
|
292
|
+
}
|
|
285
293
|
log('OK Installed bin (statusline, gmtr-login, render-claude-hooks)');
|
|
286
294
|
|
|
287
295
|
// Core dependencies (routing.ts required by GMTRPromptEnricher hook)
|
|
@@ -596,6 +604,9 @@ function verify(url: string, token: string): boolean {
|
|
|
596
604
|
'core/routing.ts',
|
|
597
605
|
'bin/statusline.ts',
|
|
598
606
|
'bin/gmtr-login.ts',
|
|
607
|
+
'bin/lib/git.ts',
|
|
608
|
+
'bin/lib/config.ts',
|
|
609
|
+
'bin/lib/stdin.ts',
|
|
599
610
|
'CLAUDE.md',
|
|
600
611
|
]) {
|
|
601
612
|
check(existsSync(join(CLIENT_DIR, f)), f, `${f} MISSING`);
|
|
@@ -0,0 +1,57 @@
|
|
|
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 rawUrl =
|
|
43
|
+
process.env.GMTR_URL ||
|
|
44
|
+
(typeof json?.url === 'string' && json.url) ||
|
|
45
|
+
DEFAULT_URL;
|
|
46
|
+
// GMTR_URL is conventionally the MCP endpoint (e.g. https://api.gramatr.com/mcp).
|
|
47
|
+
// REST calls need the API base — strip a trailing /mcp segment.
|
|
48
|
+
const url = (rawUrl as string).replace(/\/mcp\/?$/, '');
|
|
49
|
+
|
|
50
|
+
const token =
|
|
51
|
+
process.env.GMTR_TOKEN ||
|
|
52
|
+
process.env.AIOS_MCP_TOKEN ||
|
|
53
|
+
(typeof json?.token === 'string' && json.token) ||
|
|
54
|
+
null;
|
|
55
|
+
|
|
56
|
+
return { url: url as string, token };
|
|
57
|
+
}
|
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
|
+
}
|
package/bin/lib/stdin.ts
ADDED
|
@@ -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
|
|
3
|
+
* gramatr status line — thin client shim (#495).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/core/formatting.ts
CHANGED
|
@@ -40,7 +40,19 @@ function formatMemoryContext(route: RouteResponse): string[] {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function buildUserPromptAdditionalContext(route: RouteResponse): string {
|
|
43
|
-
const lines = [
|
|
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.
|
|
3
|
+
"version": "0.3.65",
|
|
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",
|