peaks-loop 4.0.26 → 4.0.28

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.
@@ -161,8 +161,10 @@ export interface ContextPercentProbe {
161
161
  * - `transcript-estimate` (v2.14.0 Mac-aware: recursive
162
162
  * readdir under ~/.claude/projects/
163
163
  * finds <sid>.jsonl and reports
164
- * bytes/256K; a real signal, NOT
165
- * a hard gate)
164
+ * contextTokens / contextWindowTokens
165
+ * from the LATEST message.usage entry —
166
+ * token-based + model-aware; a real
167
+ * signal, NOT a hard gate)
166
168
  * - `conservative-fallback` (no signal available — caller
167
169
  * MUST NOT treat as hard gate)
168
170
  * Future IDEs may add per-ide sources; the type accepts any
@@ -171,6 +173,19 @@ export interface ContextPercentProbe {
171
173
  readonly source: string;
172
174
  readonly rawBytes?: number;
173
175
  readonly capacityBytes?: number;
176
+ /**
177
+ * Token-based numerator for `transcript-estimate`: the observed
178
+ * `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`
179
+ * from the LATEST transcript `message.usage` entry. Undefined for byte /
180
+ * percent sources (`user-overridden`, `${ideId}-env`, `statusline-poll`).
181
+ */
182
+ readonly rawTokens?: number;
183
+ /**
184
+ * Token-based denominator for `transcript-estimate`: the model's context
185
+ * window in tokens (`modelContextWindowTokens`). Undefined for byte /
186
+ * percent sources.
187
+ */
188
+ readonly capacityTokens?: number;
174
189
  readonly ide: string;
175
190
  readonly capturedAt: string;
176
191
  }
@@ -6,12 +6,11 @@
6
6
  * Fails when the version drifts, when the binary is missing, or
7
7
  * when the package is not resolvable at all.
8
8
  *
9
- * Slice rid-CG-003 (spike follow-up #3): also detects which managed
10
- * codegraph directory is in use — `.peaks/.codegraph/` (preferred)
11
- * or root `.codegraph/` (legacy) — via the injected `managedPathProbe`.
9
+ * The check also reports the managed codegraph data directory in use —
10
+ * always the root `.codegraph/` — via the injected `managedPathProbe`.
12
11
  * The CG-007 yarn-pnp fallback is preserved as the package-resolution
13
- * default; the managed-path probe defaults to the same fs-walk-style
14
- * helper and is independently injectable for tests.
12
+ * default; the managed-path probe defaults to the same root-only
13
+ * resolver and is independently injectable for tests.
15
14
  *
16
15
  * The probe is injected so tests do not depend on the real
17
16
  * `node_modules` resolution; the default probe uses
@@ -6,12 +6,11 @@
6
6
  * Fails when the version drifts, when the binary is missing, or
7
7
  * when the package is not resolvable at all.
8
8
  *
9
- * Slice rid-CG-003 (spike follow-up #3): also detects which managed
10
- * codegraph directory is in use — `.peaks/.codegraph/` (preferred)
11
- * or root `.codegraph/` (legacy) — via the injected `managedPathProbe`.
9
+ * The check also reports the managed codegraph data directory in use —
10
+ * always the root `.codegraph/` — via the injected `managedPathProbe`.
12
11
  * The CG-007 yarn-pnp fallback is preserved as the package-resolution
13
- * default; the managed-path probe defaults to the same fs-walk-style
14
- * helper and is independently injectable for tests.
12
+ * default; the managed-path probe defaults to the same root-only
13
+ * resolver and is independently injectable for tests.
15
14
  *
16
15
  * The probe is injected so tests do not depend on the real
17
16
  * `node_modules` resolution; the default probe uses
@@ -100,45 +99,30 @@ function defaultCodegraphProbe() {
100
99
  version,
101
100
  binaryPath,
102
101
  binaryExists,
103
- // Slice rid-CG-003 — resolve the managed codegraph directory
104
- // relative to the cwd the doctor itself was invoked from.
105
- // Best-effort: when the operator ran `peaks doctor` outside a
106
- // project (cwd has neither `.peaks/.codegraph/` nor
107
- // `.codegraph/`) we still report `fresh-preferred` so the check
108
- // message names the canonical future location.
102
+ // Resolve the managed codegraph directory relative to the cwd the
103
+ // doctor itself was invoked from. Root-only: always names
104
+ // `<cwd>/.codegraph/`.
109
105
  managedPath: detectManagedCodegraphPath(process.cwd())
110
106
  };
111
107
  return result;
112
108
  }
113
109
  /**
114
- * Slice rid-CG-003 — pure wrapper over `resolveCodegraphProjectRoot`
115
- * that returns a probe-shaped managed-path payload (or null when
116
- * the cwd does not look like a peaks project root).
110
+ * Pure wrapper over `resolveCodegraphProjectRoot` that returns a
111
+ * probe-shaped managed-path payload for the root `.codegraph/`.
117
112
  */
118
113
  function detectManagedCodegraphPath(cwd) {
119
- try {
120
- const location = resolveCodegraphProjectRoot(cwd);
121
- return {
122
- source: location.source,
123
- codegraphDir: location.codegraphDir,
124
- cwd: location.cwd
125
- };
126
- }
127
- catch {
128
- return null;
129
- }
114
+ const location = resolveCodegraphProjectRoot(cwd);
115
+ return {
116
+ source: location.source,
117
+ codegraphDir: location.codegraphDir,
118
+ cwd: location.cwd
119
+ };
130
120
  }
131
121
  function renderManagedPathSuffix(managedPath) {
132
122
  if (!managedPath) {
133
123
  return '';
134
124
  }
135
- if (managedPath.source === 'preferred') {
136
- return `; managed path: ${managedPath.codegraphDir} (preferred .peaks/.codegraph/)`;
137
- }
138
- if (managedPath.source === 'legacy') {
139
- return `; managed path: ${managedPath.codegraphDir} (legacy root .codegraph/ — consider moving to .peaks/.codegraph/)`;
140
- }
141
- return `; managed path: ${managedPath.codegraphDir} (fresh-preferred — no codegraph dir yet; next init will create it)`;
125
+ return `; managed path: ${managedPath.codegraphDir}`;
142
126
  }
143
127
  function runCheck(probe, managedPathProbe) {
144
128
  try {
@@ -57,7 +57,7 @@ export type DoctorReport = {
57
57
  };
58
58
  };
59
59
  export type CodegraphManagedPathInfo = {
60
- source: 'preferred' | 'legacy' | 'fresh-preferred';
60
+ source: 'root';
61
61
  codegraphDir: string;
62
62
  cwd: string;
63
63
  };
@@ -67,14 +67,11 @@ export type CodegraphCapabilityProbe = {
67
67
  binaryPath: string;
68
68
  binaryExists: boolean;
69
69
  /**
70
- * Slice rid-CG-003 — preferred-path resolution result. When the
71
- * probe can detect `.peaks/.codegraph/` or `.codegraph/` inside
72
- * `process.cwd()` it sets `managedPath`; otherwise null (e.g.
73
- * when the operator invoked `peaks doctor` outside a project).
74
- *
75
- * `source: 'preferred'` — `.peaks/.codegraph/` exists
76
- * `source: 'legacy'` — only `.codegraph/` exists
77
- * `source: 'fresh-preferred'` — neither exists; defaults to preferred
70
+ * Root-only managed-path resolution result. The check resolves the
71
+ * single codegraph data directory, `<cwd>/.codegraph/`, relative to
72
+ * the cwd the doctor was invoked from; null only when resolution is
73
+ * unavailable (e.g. the operator invoked `peaks doctor` outside a
74
+ * resolvable directory).
78
75
  */
79
76
  managedPath: CodegraphManagedPathInfo | null;
80
77
  };
@@ -207,12 +204,11 @@ export type DoctorOptions = {
207
204
  skillsBaseDir?: string;
208
205
  codegraphProbe?: () => CodegraphCapabilityProbe;
209
206
  /**
210
- * Slice rid-CG-003 — optional override for the managed-codegraph
211
- * path detection inside the `capability:codegraph` check. When
212
- * omitted, the check uses the default fs helper
213
- * (`resolveCodegraphProjectRoot(process.cwd())`). Tests inject a
214
- * custom probe to drive preferred / legacy / fresh-preferred
215
- * outcomes without monkey-patching `process.cwd()`.
207
+ * Optional override for the managed-codegraph path detection inside
208
+ * the `capability:codegraph` check. When omitted, the check uses
209
+ * the default resolver (`resolveCodegraphProjectRoot(process.cwd())`).
210
+ * Tests inject a custom probe without monkey-patching
211
+ * `process.cwd()`.
216
212
  */
217
213
  codegraphManagedPathProbe?: () => CodegraphManagedPathInfo | null;
218
214
  skillPresenceProbe?: () => DoctorSkillPresence | null;
@@ -1,18 +1,25 @@
1
1
  import type { IdeAdapter } from '../ide-types.js';
2
2
  /**
3
- * Claude Code adapter —— peaks-loop 的"起源 IDE"。
4
- *
5
- * 该 adapter 从原 `src/services/skills/hooks-settings-service.ts` 提取,保持
6
- * 字节级兼容:用户在 Claude Code 环境下跑 `peaks hooks install` 产出的
7
- * `.claude/settings.json` 与 refactor 前逐字节相同。
3
+ * Resolve the currently-active Claude Code model id from a runtime env map.
4
+ * Returns the first non-empty `CLAUDE_CODE_MODEL_ENV_VARS` value (trimmed), or
5
+ * `undefined` when none is present. Pure + exported for tests; the caller falls
6
+ * back to the transcript `message.model` when this returns undefined.
7
+ */
8
+ export declare function resolveClaudeModelFromEnv(env: NodeJS.ProcessEnv | undefined): string | undefined;
9
+ /**
10
+ * Model-aware context-window size in tokens.
8
11
  *
9
- * 字段解释(见 PRD AC-1):
10
- * - dirName = '.claude' : Claude Code 项目根下的 settings 目录
11
- * - settingsFileName = 'settings.json'
12
- * - envVar = 'CLAUDE_PROJECT_DIR' : Claude Code 注入的 env 变量,用于 ${...} 占位
13
- * - hookEvent = 'PreToolUse' : Claude Code hook 数组 key
14
- * - toolMatcher = 'Bash' | 'Task' : PreToolUse 数组元素的 matcher 字段
12
+ * Detection rule (documented):
13
+ * 1. Empty / unknown model → DEFAULT_CONTEXT_WINDOW_TOKENS (200_000).
14
+ * 2. Suffix heuristic — a model id containing `1m` (case-insensitive) is
15
+ * treated as 1M-context.
16
+ * 3. Explicit allowlist — known 1M Claude model ids
17
+ * (ONE_MILLION_CONTEXT_MODELS).
18
+ * 4. Everything else → 200_000 (the safe default).
15
19
  *
16
- * 不可消除的 per-IDE 字段(见 tech-doc.md §1.3)。
20
+ * Callers MAY additionally infer ≥1M from the observed token count: if
21
+ * `contextTokens > DEFAULT_CONTEXT_WINDOW_TOKENS`, the model cannot be a
22
+ * 200K model and must be ≥1M (see `readClaudeTranscriptEstimate`).
17
23
  */
24
+ export declare function modelContextWindowTokens(model: string): number;
18
25
  export declare const CLAUDE_CODE_ADAPTER: IdeAdapter;
@@ -1,3 +1,4 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
1
2
  import { homedir } from 'node:os';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { claudeCodeSubAgentDispatcher } from '../../dispatch/sub-agent-dispatcher.js';
@@ -17,6 +18,346 @@ import { claudeCodeSubAgentDispatcher } from '../../dispatch/sub-agent-dispatche
17
18
  *
18
19
  * 不可消除的 per-IDE 字段(见 tech-doc.md §1.3)。
19
20
  */
21
+ /**
22
+ * Read Claude Code's statusline state file
23
+ * (`~/.claude/statusline-state.json`) and parse a context-percent key.
24
+ * Moved from the generic reader in slice
25
+ * 2026-09-02-vendor-neutral-context-probe — Claude-specific paths now live
26
+ * only in the Claude Code adapter.
27
+ */
28
+ function readClaudeStatuslinePercent() {
29
+ const path = join(homedir(), '.claude', 'statusline-state.json');
30
+ if (!existsSync(path))
31
+ return null;
32
+ try {
33
+ const json = JSON.parse(readFileSync(path, 'utf8'));
34
+ const candidates = ['contextPercent', 'context_usage_percent', 'contextPercentUsed'];
35
+ for (const key of candidates) {
36
+ const raw = json[key];
37
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
38
+ return raw > 1.5 ? raw / 100 : Math.max(0, Math.min(1, raw));
39
+ }
40
+ }
41
+ }
42
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
43
+ if (err instanceof ReferenceError)
44
+ throw err; // surface module-load bugs
45
+ if (err instanceof SyntaxError)
46
+ throw err; // surface parse bugs (e.g. broken statusline JSON)
47
+ return null; // only swallow IO errors
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Recursive search for `<outerSessionId>.jsonl` under `projectsDir`. The
53
+ * Mac layout encodes the cwd as a single hash directory; on Mac Claude Code
54
+ * nests the transcript under that hash with an extra level of subdirectory we
55
+ * cannot predict ahead of time. A flat readdir misses that branch and returns
56
+ * null — the silent-failure mode this recursion closes.
57
+ *
58
+ * Moved from the generic reader in slice
59
+ * 2026-09-02-vendor-neutral-context-probe. The lookup key is the OUTER
60
+ * session id (Claude Code names its transcript by the outer session UUID),
61
+ * NOT the peaks session id.
62
+ */
63
+ function findTranscriptJsonl(projectsDir, outerSessionId) {
64
+ if (!existsSync(projectsDir))
65
+ return null;
66
+ try {
67
+ const stack = [projectsDir];
68
+ while (stack.length > 0) {
69
+ const dir = stack.pop();
70
+ if (dir === undefined)
71
+ break;
72
+ const entries = readdirSync(dir, { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ const full = join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ stack.push(full);
77
+ }
78
+ else if (entry.isFile() && entry.name === `${outerSessionId}.jsonl`) {
79
+ return full;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
85
+ if (err instanceof ReferenceError)
86
+ throw err; // surface module-load bugs
87
+ if (err instanceof SyntaxError)
88
+ throw err; // surface parse bugs
89
+ return null; // only swallow IO errors
90
+ }
91
+ return null;
92
+ }
93
+ /** 1M-context window size in tokens (documented single choice: 1,000,000). */
94
+ const ONE_MILLION_CONTEXT_TOKENS = 1_000_000;
95
+ /** Safe-default (non-1M) context window size in tokens. */
96
+ const DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000;
97
+ /** Reverse-scan chunk size in bytes (keeps memory bounded on multi-MB transcripts). */
98
+ const TRANSCRIPT_SCAN_CHUNK_BYTES = 64 * 1024;
99
+ /**
100
+ * Known 1M-context Claude model id prefixes whose ids do NOT carry a `1m`
101
+ * suffix (e.g. `claude-sonnet-4-5-20250929`). The substring match is
102
+ * intentionally generous — every `claude-sonnet-4*` / `claude-opus-4*`
103
+ * variant is 1M-context.
104
+ */
105
+ const ONE_MILLION_CONTEXT_MODELS = ['claude-opus-4', 'claude-sonnet-4'];
106
+ /**
107
+ * Claude Code runtime env vars that may carry the currently-active model id,
108
+ * in documented precedence order (first non-empty value wins):
109
+ * 1. ANTHROPIC_MODEL — explicit per-run model override
110
+ * 2. ANTHROPIC_DEFAULT_OPUS_MODEL — default Opus fallback
111
+ * 3. ANTHROPIC_DEFAULT_SONNET_MODEL — default Sonnet fallback
112
+ * 4. ANTHROPIC_DEFAULT_HAIKU_MODEL — default Haiku fallback
113
+ * 5. ANTHROPIC_DEFAULT_FABLE_MODEL — default Fable fallback
114
+ * 6. CLAUDE_CODE_SUBAGENT_MODEL — sub-agent model (used when the others
115
+ * are absent, e.g. a sub-agent-only env)
116
+ *
117
+ * Why env-first: the transcript's `message.model` often drops Claude Code's
118
+ * `[1M]` / `[200K]` suffix marker (observed: `deepseek-v4-flash`), while the
119
+ * runtime env vars above carry it (`deepseek-v4-flash[1M]`). Reading them
120
+ * first lets the window resolver see the true context window. This family is
121
+ * vendor-specific, so it lives ONLY in the claude-code adapter.
122
+ */
123
+ const CLAUDE_CODE_MODEL_ENV_VARS = [
124
+ 'ANTHROPIC_MODEL',
125
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
126
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
127
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
128
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL',
129
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
130
+ ];
131
+ /**
132
+ * Resolve the currently-active Claude Code model id from a runtime env map.
133
+ * Returns the first non-empty `CLAUDE_CODE_MODEL_ENV_VARS` value (trimmed), or
134
+ * `undefined` when none is present. Pure + exported for tests; the caller falls
135
+ * back to the transcript `message.model` when this returns undefined.
136
+ */
137
+ export function resolveClaudeModelFromEnv(env) {
138
+ if (!env)
139
+ return undefined;
140
+ for (const name of CLAUDE_CODE_MODEL_ENV_VARS) {
141
+ const value = env[name];
142
+ if (typeof value === 'string') {
143
+ const trimmed = value.trim();
144
+ if (trimmed.length > 0)
145
+ return trimmed;
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Model-aware context-window size in tokens.
152
+ *
153
+ * Detection rule (documented):
154
+ * 1. Empty / unknown model → DEFAULT_CONTEXT_WINDOW_TOKENS (200_000).
155
+ * 2. Suffix heuristic — a model id containing `1m` (case-insensitive) is
156
+ * treated as 1M-context.
157
+ * 3. Explicit allowlist — known 1M Claude model ids
158
+ * (ONE_MILLION_CONTEXT_MODELS).
159
+ * 4. Everything else → 200_000 (the safe default).
160
+ *
161
+ * Callers MAY additionally infer ≥1M from the observed token count: if
162
+ * `contextTokens > DEFAULT_CONTEXT_WINDOW_TOKENS`, the model cannot be a
163
+ * 200K model and must be ≥1M (see `readClaudeTranscriptEstimate`).
164
+ */
165
+ export function modelContextWindowTokens(model) {
166
+ const m = model.trim().toLowerCase();
167
+ if (m.length === 0)
168
+ return DEFAULT_CONTEXT_WINDOW_TOKENS;
169
+ if (m.includes('1m'))
170
+ return ONE_MILLION_CONTEXT_TOKENS;
171
+ for (const known of ONE_MILLION_CONTEXT_MODELS) {
172
+ if (m.includes(known))
173
+ return ONE_MILLION_CONTEXT_TOKENS;
174
+ }
175
+ return DEFAULT_CONTEXT_WINDOW_TOKENS;
176
+ }
177
+ /** A non-negative finite number, or null when the value is not numeric. */
178
+ function numericTokenCount(value) {
179
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
180
+ return value;
181
+ return null;
182
+ }
183
+ /**
184
+ * Parse a single jsonl line into its token count + model id. Returns null
185
+ * when the line has no `message.usage` object with numeric token fields.
186
+ */
187
+ function parseTranscriptUsageLine(line) {
188
+ if (line.length === 0)
189
+ return null;
190
+ let json;
191
+ try {
192
+ json = JSON.parse(line);
193
+ }
194
+ catch {
195
+ return null; // non-JSON line (blank / corrupt) — skip
196
+ }
197
+ if (typeof json !== 'object' || json === null)
198
+ return null;
199
+ const record = json;
200
+ const message = record.message;
201
+ if (typeof message !== 'object' || message === null)
202
+ return null;
203
+ const msg = message;
204
+ const usage = msg.usage;
205
+ if (typeof usage !== 'object' || usage === null)
206
+ return null;
207
+ const u = usage;
208
+ const inputTokens = numericTokenCount(u.input_tokens);
209
+ const cacheRead = numericTokenCount(u.cache_read_input_tokens);
210
+ const cacheCreation = numericTokenCount(u.cache_creation_input_tokens);
211
+ if (inputTokens === null && cacheRead === null && cacheCreation === null)
212
+ return null;
213
+ const contextTokens = (inputTokens ?? 0) + (cacheRead ?? 0) + (cacheCreation ?? 0);
214
+ // Model id lives at `message.model`, falling back to a top-level `model`.
215
+ const model = typeof msg.model === 'string'
216
+ ? msg.model
217
+ : typeof record.model === 'string' ? record.model : '';
218
+ return { contextTokens, model };
219
+ }
220
+ /**
221
+ * Reverse-scan the transcript jsonl (from the END) for the LATEST entry that
222
+ * carries a numeric `message.usage`. The file can be many MB; it is read in
223
+ * backward chunks of TRANSCRIPT_SCAN_CHUNK_BYTES — never fully into memory —
224
+ * and stops at the first (newest) usable entry.
225
+ */
226
+ function findLatestTranscriptUsage(filePath) {
227
+ let fd = null;
228
+ try {
229
+ const size = statSync(filePath).size;
230
+ if (size === 0)
231
+ return null;
232
+ fd = openSync(filePath, 'r');
233
+ let position = size;
234
+ let carry = ''; // partial line head carried into the next (older) chunk
235
+ while (position > 0) {
236
+ const readLen = Math.min(TRANSCRIPT_SCAN_CHUNK_BYTES, position);
237
+ position -= readLen;
238
+ const buf = Buffer.alloc(readLen);
239
+ const bytesRead = readSync(fd, buf, 0, readLen, position);
240
+ if (bytesRead <= 0)
241
+ break;
242
+ const lines = (buf.toString('utf8', 0, bytesRead) + carry).split('\n');
243
+ carry = lines[0] ?? '';
244
+ for (let i = lines.length - 1; i >= 1; i--) {
245
+ const line = lines[i];
246
+ if (line === undefined)
247
+ continue;
248
+ const parsed = parseTranscriptUsageLine(line);
249
+ if (parsed !== null)
250
+ return parsed;
251
+ }
252
+ }
253
+ // The final carry is the first line of the file (complete, since it starts at byte 0).
254
+ if (carry.length > 0) {
255
+ const parsed = parseTranscriptUsageLine(carry);
256
+ if (parsed !== null)
257
+ return parsed;
258
+ }
259
+ return null;
260
+ }
261
+ catch (err) {
262
+ // Narrow: surface module-load / parse bugs, swallow IO errors only
263
+ // (mirrors the other adapter read helpers' catch discipline).
264
+ if (err instanceof ReferenceError)
265
+ throw err;
266
+ if (err instanceof SyntaxError)
267
+ throw err;
268
+ return null;
269
+ }
270
+ finally {
271
+ if (fd !== null) {
272
+ try {
273
+ closeSync(fd);
274
+ }
275
+ catch { /* best-effort */ }
276
+ }
277
+ }
278
+ }
279
+ /**
280
+ * Resolve the context window for a transcript usage entry. Prefers the
281
+ * explicit `modelContextWindowTokens(model)` mapping; when the observed token
282
+ * count contradicts it (tokens exceed the mapped window), the model must be
283
+ * ≥1M, so bump to the 1M window.
284
+ */
285
+ function resolveContextWindowTokens(model, contextTokens) {
286
+ const window = modelContextWindowTokens(model);
287
+ return contextTokens > window ? ONE_MILLION_CONTEXT_TOKENS : window;
288
+ }
289
+ /**
290
+ * Conservative transcript-estimate fallback. Recursively searches
291
+ * `~/.claude/projects/<hash>/<outerSessionId-or-nested>.jsonl` (Mac may nest
292
+ * the jsonl under an extra directory we cannot predict ahead of time) and
293
+ * estimates `ratio = contextTokens / contextWindowTokens` from the LATEST
294
+ * `message.usage` entry — token-based + model-aware, NOT the old
295
+ * `bytes / 256KB` (which over-fired because the transcript grows unboundedly).
296
+ * Tagged `'transcript-estimate'` (v2.14.0) so callers know it is a real
297
+ * signal, NOT a hard gate.
298
+ *
299
+ * Window model resolution is env-first: when `envModel` is present, its id
300
+ * (which Claude Code stamps with the `[1M]` / `[200K]` suffix) drives the
301
+ * window; otherwise the transcript `message.model` is used.
302
+ */
303
+ function readClaudeTranscriptEstimate(outerSessionId, envModel) {
304
+ const projectsDir = join(homedir(), '.claude', 'projects');
305
+ const path = findTranscriptJsonl(projectsDir, outerSessionId);
306
+ if (path === null)
307
+ return null;
308
+ const latest = findLatestTranscriptUsage(path);
309
+ if (latest === null)
310
+ return null;
311
+ const model = envModel !== undefined && envModel.trim().length > 0 ? envModel : latest.model;
312
+ const contextWindowTokens = resolveContextWindowTokens(model, latest.contextTokens);
313
+ const ratio = Math.min(1, latest.contextTokens / contextWindowTokens);
314
+ return { ratio, contextTokens: latest.contextTokens, contextWindowTokens };
315
+ }
316
+ /**
317
+ * Claude Code's vendor-specific context-percent fallback, exposed as
318
+ * `IdeCompactProfile.readContextPercentFallback`. The generic reader calls
319
+ * this only when the primary env-var probe misses; only the adapter knows the
320
+ * Claude-specific statusline + transcript paths. Resolution order:
321
+ * 1. statusline poll (`~/.claude/statusline-state.json`)
322
+ * 2. transcript estimate — looks up `<outerSessionId>.jsonl` under
323
+ * `~/.claude/projects/<hash>/...` using the OUTER session id (Claude
324
+ * names its transcript by the outer session UUID, not the peaks sid),
325
+ * and estimates `contextTokens / contextWindowTokens` from the LATEST
326
+ * `message.usage` entry (token-based + model-aware). The window model
327
+ * resolves env-first via `resolveClaudeModelFromEnv(input.env)` (which
328
+ * carries the `[1M]` suffix the transcript often drops), falling back to
329
+ * the transcript `message.model` when env is empty.
330
+ * Returns `null` when neither yields a signal → the reader emits
331
+ * `conservative-fallback`.
332
+ */
333
+ function readContextPercentFallback(input) {
334
+ const capturedAt = new Date().toISOString();
335
+ // Byte-based capacity is carried only for the percent path (statusline-poll
336
+ // returns a 0..1 ratio; capacityBytes is metadata there). The
337
+ // transcript-estimate path is token-based and surfaces `capacityTokens`
338
+ // (the model window) instead — see readClaudeTranscriptEstimate.
339
+ const capacityBytes = 256 * 1024;
340
+ const ide = 'claude-code';
341
+ const statusline = readClaudeStatuslinePercent();
342
+ if (statusline !== null) {
343
+ return { ratio: statusline, source: 'statusline-poll', capacityBytes, ide, capturedAt };
344
+ }
345
+ if (typeof input.outerSessionId === 'string' && input.outerSessionId.length > 0) {
346
+ const envModel = resolveClaudeModelFromEnv(input.env);
347
+ const estimate = readClaudeTranscriptEstimate(input.outerSessionId, envModel);
348
+ if (estimate !== null) {
349
+ return {
350
+ ratio: estimate.ratio,
351
+ source: 'transcript-estimate',
352
+ rawTokens: estimate.contextTokens,
353
+ capacityTokens: estimate.contextWindowTokens,
354
+ ide,
355
+ capturedAt
356
+ };
357
+ }
358
+ }
359
+ return null;
360
+ }
20
361
  export const CLAUDE_CODE_ADAPTER = {
21
362
  id: 'claude-code',
22
363
  displayName: 'Claude Code',
@@ -71,7 +412,8 @@ export const CLAUDE_CODE_ADAPTER = {
71
412
  envVarForContextPercent: 'CLAUDE_CONTEXT_USAGE_PERCENT',
72
413
  compactCommand: 'claude --compact',
73
414
  compactPathway: 'ide-native',
74
- postCompactDetectCommand: 'peaks compact auto --json'
415
+ postCompactDetectCommand: 'peaks compact auto --json',
416
+ readContextPercentFallback
75
417
  },
76
418
  // Slice #011: standards profile. Claude Code reads its constitution at
77
419
  // CLAUDE.md + module-level rules under .claude/rules/**. The values mirror
@@ -12,6 +12,7 @@
12
12
  * 其他全部归一化到 peaks 内部模型(见 hook-protocol.ts)。
13
13
  */
14
14
  import type { SubAgentDispatcher } from '../dispatch/sub-agent-dispatcher.js';
15
+ import type { ContextPercentProbe } from '../context/auto-compact-types.js';
15
16
  export type IdeId = 'claude-code' | 'trae' | 'codex' | 'cursor' | 'qoder' | 'tongyi-lingma' | 'hermes' | 'openclaw' | 'zcode';
16
17
  export interface IdeCapabilities {
17
18
  /** peaks gate enforce 是否适用该 IDE(必备) */
@@ -200,6 +201,44 @@ export interface IdeCompactProfile {
200
201
  * the orchestrator polls `envVarForContextPercent` directly.
201
202
  */
202
203
  readonly postCompactDetectCommand?: string;
204
+ /**
205
+ * Optional vendor-specific fallback the generic `readContextPercent`
206
+ * reader calls when the primary env-var probe misses. Returns a
207
+ * completed `ContextPercentProbe` (e.g. `statusline-poll` /
208
+ * `transcript-estimate`) or `null` when the adapter has no signal —
209
+ * the reader then falls through to `conservative-fallback`.
210
+ *
211
+ * Keeps IDE-specific filesystem / env knowledge (Claude Code's
212
+ * `~/.claude/statusline-state.json` + `~/.claude/projects` transcript
213
+ * layout, etc.) inside the adapter, not the generic reader. Adapters
214
+ * that do not opt in simply omit the field; new IDEs are addable
215
+ * without touching the generic reader.
216
+ *
217
+ * Added in slice 2026-09-02-vendor-neutral-context-probe.
218
+ */
219
+ readonly readContextPercentFallback?: (input: ContextPercentFallbackInput) => ContextPercentProbe | null;
220
+ }
221
+ /**
222
+ * Input the generic `readContextPercent` reader passes to an adapter's
223
+ * optional `IdeCompactProfile.readContextPercentFallback` hook. The adapter
224
+ * owns the vendor-specific fallback logic (statusline poll, transcript
225
+ * lookup, etc.); the generic reader stays IDE-agnostic.
226
+ *
227
+ * Added in slice 2026-09-02-vendor-neutral-context-probe.
228
+ */
229
+ export interface ContextPercentFallbackInput {
230
+ /** Project root (the probe's `--project` anchor). */
231
+ readonly projectRoot: string;
232
+ /** Peaks session id (NOT the harness / IDE transcript id). */
233
+ readonly sessionId: string;
234
+ /**
235
+ * Outer (harness / IDE) session id — the id the IDE uses to name its
236
+ * transcript / session files (e.g. a UUID). Optional: when unresolved,
237
+ * adapters whose fallback depends on it should return `null`.
238
+ */
239
+ readonly outerSessionId?: string | undefined;
240
+ /** Injectable env (defaults to process.env in the reader). */
241
+ readonly env?: NodeJS.ProcessEnv | undefined;
203
242
  }
204
243
  /**
205
244
  * Per-IDE standards-file location + format profile. Used by the
@@ -38,6 +38,17 @@ export type BindingStatusView = {
38
38
  * helper that does no filesystem writes; safe to call from any test.
39
39
  */
40
40
  export declare function loadBindingStatus(projectRoot: string): BindingStatusView;
41
+ export declare function readOuterSessionId(): string;
42
+ /**
43
+ * Resolve the outer (harness / IDE) session id for the auto-compact
44
+ * context-percent probe. Order: env signal first (PEAKS_OUTER_SESSION_ID /
45
+ * CLAUDE_CODE_SESSION_ID), then the bound peaks session's recorded
46
+ * `outerSessionId` (`session.json` meta). Returns `undefined` when neither is
47
+ * available — the caller passes `undefined` through and the adapter's fallback
48
+ * returns null → conservative-fallback. Vendor-neutral: this helper has no
49
+ * IDE-specific naming; the env-var read lives alongside the binding layer.
50
+ */
51
+ export declare function resolveOuterSessionId(projectRoot: string, sessionId: string, env?: NodeJS.ProcessEnv): string | undefined;
41
52
  /**
42
53
  * Render the binding as a pipeable ASCII table. The columns are fixed
43
54
  * (no truncation, no wrapping) so a downstream `awk` / `cut` pipeline