peaks-loop 4.0.34 → 4.0.36

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
  3. package/dist/cli/commands/code-runtime-commands.js +57 -2
  4. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  5. package/dist/cli/commands/core/doctor-command.js +44 -2
  6. package/dist/cli/commands/core/memory-command.js +65 -3
  7. package/dist/cli/commands/dispatch-commands.js +19 -5
  8. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  9. package/dist/cli/commands/memory-commands.d.ts +59 -0
  10. package/dist/cli/commands/memory-commands.js +195 -19
  11. package/dist/cli/commands/request-commands.d.ts +8 -0
  12. package/dist/cli/commands/request-commands.js +23 -2
  13. package/dist/cli/commands/sub-agent-commands.js +2 -0
  14. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  15. package/dist/cli/commands/wave-plan-commands.js +93 -0
  16. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  17. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  18. package/dist/services/context/context-audit.d.ts +100 -0
  19. package/dist/services/context/context-audit.js +322 -0
  20. package/dist/services/context/context-schema.d.ts +1 -1
  21. package/dist/services/context/memory-index-reader.d.ts +26 -0
  22. package/dist/services/context/memory-index-reader.js +62 -30
  23. package/dist/services/context/memory-preflight-config.d.ts +33 -0
  24. package/dist/services/context/memory-preflight-config.js +32 -2
  25. package/dist/services/context/memory-preflight-service.d.ts +20 -1
  26. package/dist/services/context/memory-preflight-service.js +198 -31
  27. package/dist/services/context/summary-view.d.ts +54 -0
  28. package/dist/services/context/summary-view.js +114 -0
  29. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  30. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  31. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  32. package/dist/services/dispatch/session-capsule.js +56 -0
  33. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  34. package/dist/services/dispatch/slice-dag.js +9 -1
  35. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  36. package/dist/services/dispatch/test-tool-detection.js +14 -13
  37. package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
  38. package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
  39. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  40. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  41. package/dist/services/ide/ide-types.d.ts +15 -0
  42. package/dist/services/job/job-types.d.ts +3 -3
  43. package/dist/services/memory/memory-ingest-service.d.ts +79 -0
  44. package/dist/services/memory/memory-ingest-service.js +225 -0
  45. package/dist/services/memory/memory-rotate-service.d.ts +88 -0
  46. package/dist/services/memory/memory-rotate-service.js +373 -0
  47. package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
  48. package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
  49. package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
  50. package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
  51. package/dist/services/memory/project-memory-service/index/search.js +14 -24
  52. package/dist/services/memory/project-memory-service/index.d.ts +7 -3
  53. package/dist/services/memory/project-memory-service/index.js +6 -2
  54. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +80 -3
  55. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +167 -28
  56. package/dist/services/memory/project-memory-service/types.d.ts +31 -1
  57. package/dist/services/memory/project-memory-service/types.js +76 -1
  58. package/dist/services/preferences/preferences-types.d.ts +14 -0
  59. package/dist/services/preferences/preferences-types.js +8 -0
  60. package/dist/services/share/run-state-contract.d.ts +1 -1
  61. package/package.json +5 -5
  62. package/skills/bee/peaks-qa/SKILL.md +2 -0
  63. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  64. package/skills/bee/peaks-rd/SKILL.md +2 -0
  65. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  66. package/skills/bee/peaks-txt/SKILL.md +2 -0
  67. package/skills/bee/peaks-ui/SKILL.md +2 -0
  68. package/skills/peaks-code/SKILL.md +9 -1
  69. package/skills/peaks-code/references/context-governance.md +29 -0
  70. package/skills/peaks-code/references/runbook.md +6 -0
  71. package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
  72. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -1,3 +1,4 @@
1
+ import { formatTestToolDetection } from '../dispatch/test-tool-detection.js';
1
2
  /**
2
3
  * Slice 2026-07-29-worktree-l1: Layer 1 of the 3-layer worktree governance
3
4
  * defence. The block below is prepended to every sub-agent dispatch system
@@ -27,14 +28,14 @@ You MUST NOT follow the superpowers chain for worktree decisions:
27
28
  - superpowers:brainstorming → superpowers:writing-plans →
28
29
  superpowers:subagent-driven-development → superpowers:using-git-worktrees
29
30
 
30
- This chain ends with \`git worktree add\` (superpowers:using-git-worktrees SKILL.md line 96). It bypasses peaks-loop's L2 hook gate and L3 IDE \`permissions.deny\`. Even when L3 denies the terminal Skill, the chain has already taught you to use raw \`git worktree add\`, so L3 is not sufficient.
31
+ That chain ends with \`git worktree add\` (superpowers:using-git-worktrees SKILL.md line 96), which bypasses peaks-loop's L2 hook gate and L3 IDE \`permissions.deny\`. L3 denial is NOT sufficient — by then the chain has already taught raw \`git worktree add\`.
31
32
 
32
33
  For worktree operations, use ONLY:
33
34
 
34
35
  - \`peaks worktree spawn --rid <rid> --ttl <duration> --purpose <text>\` (after rid-L2-extended ships)
35
36
  - until then: \`peaks worktree auth grant --rid <id> --reason <text> --ttl <5m>\`
36
37
 
37
- The superpowers skills remain available as REFERENCE material for brainstorming / planning techniques, but MUST NOT be used as a workflow. peaks-rd's own PRD/RD artifacts (\`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md\`) are authoritative.
38
+ Superpowers skills remain REFERENCE material for brainstorming / planning techniques, but MUST NOT be used as a workflow. peaks-rd's own PRD/RD artifacts (\`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md\`) are authoritative.
38
39
 
39
40
  If the upstream superpowers chain suggests raw \`git worktree add\`:
40
41
 
@@ -69,20 +70,44 @@ If the upstream superpowers chain suggests raw \`git worktree add\`:
69
70
  */
70
71
  export const LIFECYCLE_RULES = `## Sub-agent lifecycle rules (locked 2026-08-01)
71
72
 
72
- - If you start a long-lived local service (vite dev, mock API, docker container, etc.), register it with \`peaks sub-agent shutdown register --pid <pid> --name <label>\` before you exit. The parent session will best-effort-kill it before merge-back.
73
- - Do NOT run E2E. The parent session runs Playwright verification once after merge-back (Task 10). Your E2E work is duplicate effort.
73
+ - If you start a long-lived local service (vite dev, mock API, docker container, etc.), register it with \`peaks sub-agent shutdown register --pid <pid> --name <label>\` before you exit; the parent session best-effort-kills it before merge-back.
74
+ - Do NOT run E2E. The parent session runs Playwright verification once after merge-back (Task 10); your E2E work is duplicate effort.
74
75
  - Do NOT call \`git merge\`, \`git pull\`, \`git rebase\`, or \`peaks worktree release\`. The parent session owns the merge-back step.
75
76
  `;
76
77
  /**
77
- * Compose the system-prompt body that the dispatch site prepends to
78
- * `formatTestToolDetection()\n\n`.
78
+ * Slice 2026-09-10-context-audit-and-discipline (Slice C): cap the sub-agent's
79
+ * FINAL report.
80
+ *
81
+ * Why (measured, session 2026-09-07-session-245530): 20 sub-agent final
82
+ * reports cost ≈ 60 KB ≈ 15K tokens of the ORCHESTRATOR's window in one
83
+ * session — the reports, not the dispatch boilerplate, were the second-largest
84
+ * consumer. The sub-agent already writes a full artifact to disk; the report
85
+ * only needs to be the index into it.
86
+ *
87
+ * QUALITY GUARD (binding): the cap removes no information. Everything the
88
+ * parent needs to ACT on stays in the report; everything longer lives in the
89
+ * artifact the parent can `Read`. The five mandatory fields below are exactly
90
+ * the ones the orchestrator must have to decide the next gate.
91
+ */
92
+ export const REPORT_CAP_BLOCK = `## Final report cap (mandatory)
93
+
94
+ Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any longer detail into the artifact file you already own — the parent can \`Read\` that file for the full detail, so nothing is lost. The report itself MUST still carry: changed files (one line each), the exact commands you ran, pass/fail counts, tsc status, and any blocker. Do NOT paste file contents, full tool output, or logs into the report.
95
+ `;
96
+ /**
97
+ * Compose the system-prompt body for a sub-agent dispatch.
98
+ *
99
+ * 2026-09-10-dispatch-block-d (Option D): the composer owns the Test Tool
100
+ * Detection injection — ONE unified block for every role, prepended first.
101
+ * Callers MUST NOT prepend `formatTestToolDetection()` themselves or the
102
+ * block is injected twice.
79
103
  *
80
104
  * Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
81
- * controller brief): when the memory block is unavailable, the caller does
82
- * `formatTestToolDetection()\n\n${taskBody}` — i.e. the final prompt is exactly
83
- * `${formatTestToolDetection()}\n\n${taskBody}`. Today's pre-change behavior
84
- * produced the same string from `src/cli/commands/dispatch-commands.ts:220`,
85
- * so the unavailable branch MUST return `taskBody` (NOT a `# title\n\n` wrap).
105
+ * controller brief): when the memory block is unavailable, the composed body is
106
+ * exactly `formatTestToolDetection() + "\n\n" + L1 + "\n" + LIFECYCLE +
107
+ * "\n" + REPORT_CAP + "\n" + contextBlock + taskBody`, so the unavailable
108
+ * branch MUST return `taskBody` unwrapped (NOT a `# title\n\n` wrap).
109
+ * (REPORT_CAP joined the stable prefix in slice
110
+ * 2026-09-10-context-audit-and-discipline, Slice C.)
86
111
  * The contract holds for callers that do not pass `codegraphBlock` (all
87
112
  * non-RD roles). Slice 2026-09-03-codegraph-preread deliberately inserts a
88
113
  * codegraph structure block (or its fail-soft unavailable note) for RD
@@ -98,15 +123,33 @@ export const LIFECYCLE_RULES = `## Sub-agent lifecycle rules (locked 2026-08-01)
98
123
  * refusal is in scope before any task-specific prose arrives.
99
124
  */
100
125
  export function buildDispatchSystemPrompt(input) {
101
- const { taskBody, memoryBlock, contextProbe, codegraphBlock, projectStackBlock, freshContextBlock } = input;
126
+ const { taskBody, memoryBlock, contextProbe, codegraphBlock, projectStackBlock, freshContextBlock, capsule } = input;
127
+ // 2026-09-10-dispatch-block-d (Option D): ONE Test Tool Detection block
128
+ // for every role — the composer owns the injection so callers MUST NOT
129
+ // prepend `formatTestToolDetection()` themselves (double injection).
130
+ const testToolText = `${formatTestToolDetection()}\n\n`;
102
131
  const contextBlock = renderContextBlock(contextProbe ?? null);
103
132
  const codegraphText = renderCodegraphBlock(codegraphBlock);
104
133
  const projectStackText = renderProjectStackBlock(projectStackBlock);
105
134
  const freshContextText = renderFreshContextBlock(freshContextBlock);
135
+ const capsuleText = renderCapsulePointer(capsule);
106
136
  if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
107
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${memoryBlock.block}\n## Task\n${taskBody}`;
137
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
108
138
  }
109
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${taskBody}`;
139
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
140
+ }
141
+ /**
142
+ * Slice 2026-09-10-dispatch-token-and-swarm §4 — session capsule pointer.
143
+ *
144
+ * QUALITY GUARD: the capsule is BACKGROUND only. The precedence line below
145
+ * is part of the contract, not decoration — anything the sub-agent must
146
+ * ACT on stays inline in the task spec. The renderer therefore always
147
+ * emits the precedence sentence whenever it emits the pointer.
148
+ */
149
+ function renderCapsulePointer(capsule) {
150
+ if (capsule === null || capsule === undefined)
151
+ return '';
152
+ return `## Shared session capsule (advisory background)\nBackground facts already established by the orchestrator (${capsule.bytes} bytes): read them with \`peaks sub-agent shared-read --batch ${capsule.batchId} --key ${capsule.key}\`. This capsule is ADVISORY BACKGROUND ONLY — it is not a task. Your task spec below is authoritative and wins on any conflict; anything you must act on is stated inline there.\n\n`;
110
153
  }
111
154
  /**
112
155
  * Slice 2026-09-03-codegraph-preread: fixed degradation string emitted
@@ -199,17 +242,89 @@ function renderContextBlock(probe) {
199
242
  : 'plenty of room — continue without compacting.';
200
243
  return `## Context window (authoritative — do NOT estimate yourself)
201
244
 
202
- Your context is **${usedPct}% used** (${freePct}% free) as measured by the IDE adapter's token-counted statusline (source: \`${probe.source}\`, IDE: \`${probe.ide}\`). This number is the SAME value \`peaks code context-now\` returns — trust it; do not derive a percentage from your message length or any other heuristic (char/4 estimates diverge from token counts by 2-4x and have caused false "context too low" reports at ${freePct}%+ free).
245
+ Context **${usedPct}% used** (${freePct}% free), token-counted by the IDE adapter's statusline (source: \`${probe.source}\`, IDE: \`${probe.ide}\`). This is the SAME value \`peaks code context-now\` returns — trust it; never derive a percentage from message length (char/4 diverges 2-4x and has caused false "context too low" reports at ${freePct}%+ free).
203
246
 
204
247
  **Action:** ${action}
205
248
 
206
- If you are tempted to declare "context pressure" or "context too low" to the parent, FIRST re-run \`peaks code context-now\` and compare its \`ratio\` field to the number above. Only report context pressure if \`peaks code context-now\` returns \`verdict: red-line\` or \`action: auto-compact-now\`.
249
+ Before telling the parent "context pressure" or "context too low", re-run \`peaks code context-now\` and compare its \`ratio\` to the number above. Report pressure ONLY if it returns \`verdict: red-line\` or \`action: auto-compact-now\`.
207
250
 
208
251
  `;
209
252
  }
210
253
  return `## Context window (no probe available)
211
254
 
212
- The orchestrator did not capture a context-fill probe before this dispatch. If you need to evaluate context pressure, run \`peaks code context-now --project <root>\` and trust its \`ratio\` field. Do not estimate from message length.
255
+ No context-fill probe was captured before this dispatch. To evaluate context pressure, run \`peaks code context-now --project <root>\` and trust its \`ratio\` field. Do not estimate from message length.
213
256
 
214
257
  `;
215
258
  }
259
+ /* ──────────────────────────────────────────────────────────────────────────
260
+ * Slice 2026-09-10-dispatch-token-and-swarm §1 — rule-presence guard.
261
+ *
262
+ * The compression + role-scoping in this file is allowed to shorten prose.
263
+ * It is NOT allowed to drop a binding rule. These token sets are the
264
+ * machine-checkable definition of "binding rule": each entry is a phrase
265
+ * that carries an obligation (MUST / MUST NOT / refused / a command the
266
+ * sub-agent is told to use or avoid). The guard test asserts that EVERY
267
+ * role's composed prompt contains EVERY token — so a future compression
268
+ * that deletes a rule fails CI instead of silently weakening the contract.
269
+ * ────────────────────────────────────────────────────────────────────────── */
270
+ /** Binding phrases every dispatch prompt must contain, for every role. */
271
+ export const BINDING_RULE_TOKENS = [
272
+ // L1 worktree governance
273
+ 'MUST NOT follow the superpowers chain',
274
+ 'superpowers:using-git-worktrees',
275
+ '`git worktree add`',
276
+ '`peaks worktree spawn --rid <rid> --ttl <duration> --purpose <text>`',
277
+ '`peaks worktree auth grant --rid <id> --reason <text> --ttl <5m>`',
278
+ 'MUST NOT be used as a workflow',
279
+ 'STOP',
280
+ 'Re-author the plan as a peaks-rd artifact',
281
+ // lifecycle rules
282
+ '`peaks sub-agent shutdown register --pid <pid> --name <label>`',
283
+ 'Do NOT run E2E',
284
+ 'Do NOT call `git merge`, `git pull`, `git rebase`',
285
+ '`peaks worktree release`',
286
+ // context window
287
+ 'do NOT estimate yourself',
288
+ '`peaks code context-now`',
289
+ '`verdict: red-line`',
290
+ // final report cap (Slice 2026-09-10-context-audit-and-discipline, Slice C)
291
+ '## Final report cap (mandatory)',
292
+ '≤ 40 lines and ≤ 2 KB',
293
+ 'the parent can `Read` that file for the full detail',
294
+ 'changed files (one line each)',
295
+ 'pass/fail counts',
296
+ 'tsc status',
297
+ // test scope — ONE unified block, byte-identical for EVERY role
298
+ '## Test Tool Detection (mandatory)',
299
+ '`package.json#scripts.test`',
300
+ 'do NOT invoke `npx <runner>`',
301
+ '## Test Scope (mandatory)',
302
+ 'PEAKS_FULL_TEST=1',
303
+ 'refused',
304
+ ];
305
+ /**
306
+ * The runner-direct-path tokens: the refusal example, the two direct paths
307
+ * the block names (`peaks test --json` to introspect; PB-5, the repo-defined
308
+ * `test` / `test:*` scripts that are NOT gated), and the two pieces of
309
+ * quality guidance that must survive any compression — never assume a
310
+ * runner without asking the user as a last resort, and prefer
311
+ * `peaks test <file>` because it resolves the local binary Windows-aware.
312
+ *
313
+ * 2026-09-10-dispatch-block-d (Option D): there is no role split any more,
314
+ * so this set is asserted IDENTICALLY for every role. The runner EXAMPLES
315
+ * were removed as part of the unification — they were never rules.
316
+ */
317
+ export const TEST_RUNNER_RULE_TOKENS = [
318
+ '`./node_modules/.bin/vitest run`',
319
+ 'PB-5',
320
+ '`peaks test --json`',
321
+ 'ask the user before assuming a runner',
322
+ '(Windows-aware)',
323
+ ];
324
+ /**
325
+ * Return the subset of `tokens` that `text` does NOT contain. Pure; used by
326
+ * the rule-presence guard and usable by any future prompt self-check.
327
+ */
328
+ export function missingRuleTokens(text, tokens) {
329
+ return tokens.filter((t) => !text.includes(t));
330
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `peaks code context-audit` — what actually fills the orchestrator's window.
3
+ *
4
+ * Slice 2026-09-10-context-audit-and-discipline (Slice A).
5
+ *
6
+ * Why this exists: `peaks code context-now` reports a RATIO only. Nothing
7
+ * reported WHAT occupies the window, so the same 40K-token mistake (dumping a
8
+ * full `peaks memory reindex --json` array four times in one session) was
9
+ * invisible until the window was 68% gone. The IDE transcript already holds
10
+ * per-message tool results, so the breakdown is derivable locally, with zero
11
+ * tokens spent asking a model.
12
+ *
13
+ * Contract:
14
+ * - READ-ONLY. The transcript is never modified.
15
+ * - FAIL-SOFT. A missing / oversized / corrupt transcript yields
16
+ * `available: false` plus a machine-readable `reason`. Never throws,
17
+ * never blocks a workflow, never exits non-zero on its own.
18
+ * - NO CONTENT. The envelope carries tool names, short command/path keys
19
+ * and byte counts — never the tool result text itself (dumping it would
20
+ * re-create the very problem this command measures).
21
+ * - BOUNDED MEMORY. The transcript can be tens of MB; it is streamed in
22
+ * fixed-size chunks with a carried partial line, never read whole.
23
+ *
24
+ * Grouping key = `(tool name, short input key)`. The key is a *stable
25
+ * summary* of the tool input — the Bash command line, the file path tail, the
26
+ * grep pattern — so "4 × the same 40KB reindex dump" collapses into ONE row
27
+ * with `count: 4` instead of four anonymous entries.
28
+ */
29
+ /** Default number of top entries emitted. */
30
+ export declare const CONTEXT_AUDIT_DEFAULT_TOP = 15;
31
+ /** Hard ceiling for `--top` — the envelope must stay small by construction. */
32
+ export declare const CONTEXT_AUDIT_MAX_TOP = 100;
33
+ /** Transcripts larger than this are reported `available:false` (fail-soft). */
34
+ export declare const CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES: number;
35
+ export interface ContextAuditEntry {
36
+ /** Tool name (`Bash`, `Read`, `Grep`, …), or `unknown` when unmatched. */
37
+ readonly tool: string;
38
+ /** Short, stable summary of the tool input (command line / path tail / pattern). */
39
+ readonly key: string;
40
+ /** Total UTF-8 bytes of every tool result in this group. */
41
+ readonly bytes: number;
42
+ /**
43
+ * Share of the session's tool-result bytes, as a PERCENTAGE in `[0, 100]`
44
+ * with one decimal (e.g. `4.2` — not the `0.042` ratio). The name and the
45
+ * value agree: `pct` means percent.
46
+ */
47
+ readonly pctOfTotal: number;
48
+ /** How many tool results landed in this group. */
49
+ readonly count: number;
50
+ }
51
+ export interface ContextAuditResult {
52
+ /** False when the transcript could not be read; see `reason`. */
53
+ readonly available: boolean;
54
+ /** Machine-readable unavailability reason (`null` when available). */
55
+ readonly reason: string | null;
56
+ /** Absolute transcript path, or `null` when unresolved. */
57
+ readonly transcriptPath: string | null;
58
+ /** Total UTF-8 bytes of all tool results seen. */
59
+ readonly totalBytes: number;
60
+ /** Number of tool-result entries seen. */
61
+ readonly entryCount: number;
62
+ /** Distinct `(tool, key)` groups — always ≥ `entries.length`. */
63
+ readonly groupCount: number;
64
+ /** Number of top entries requested. */
65
+ readonly topN: number;
66
+ /** Top-N groups, sorted by bytes descending. */
67
+ readonly entries: readonly ContextAuditEntry[];
68
+ }
69
+ export interface ContextAuditInput {
70
+ /** Outer (harness) session id — the transcript is named by it. */
71
+ readonly outerSessionId?: string | null;
72
+ /** How many top entries to emit. Clamped to `[1, CONTEXT_AUDIT_MAX_TOP]`. */
73
+ readonly topN?: number;
74
+ /** Explicit transcript path override (test seam; skips the locator). */
75
+ readonly transcriptPath?: string | null;
76
+ /** Override the too-large threshold (test seam; default 256 MB). */
77
+ readonly maxTranscriptBytes?: number;
78
+ /** Env used to detect the active IDE (default `process.env`). */
79
+ readonly env?: NodeJS.ProcessEnv;
80
+ }
81
+ /** Clamp a caller-supplied `--top` into the documented range. */
82
+ export declare function normalizeTopN(value: unknown): number;
83
+ /**
84
+ * Build the stable group key for one tool call. Unknown tools fall back to a
85
+ * clipped JSON rendering of their input so the group is still recognizable.
86
+ */
87
+ export declare function contextAuditKey(tool: string, input: unknown): string;
88
+ /**
89
+ * Audit the CURRENT session's transcript. Never throws.
90
+ *
91
+ * Unavailability reasons (all return `available: false`, exit code stays 0):
92
+ * - `no-outer-session-id` — the peaks session has no bound outer id
93
+ * - `transcript-locator-unavailable` — the active IDE adapter does not
94
+ * declare `compact.resolveTranscriptPath`
95
+ * - `transcript-not-found` — the adapter locator returned null
96
+ * - `transcript-too-large` — above `CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES`
97
+ * - `transcript-unreadable`— stat/open failed
98
+ * - `audit-failed` — any unexpected internal error
99
+ */
100
+ export declare function auditContext(input?: ContextAuditInput): ContextAuditResult;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * `peaks code context-audit` — what actually fills the orchestrator's window.
3
+ *
4
+ * Slice 2026-09-10-context-audit-and-discipline (Slice A).
5
+ *
6
+ * Why this exists: `peaks code context-now` reports a RATIO only. Nothing
7
+ * reported WHAT occupies the window, so the same 40K-token mistake (dumping a
8
+ * full `peaks memory reindex --json` array four times in one session) was
9
+ * invisible until the window was 68% gone. The IDE transcript already holds
10
+ * per-message tool results, so the breakdown is derivable locally, with zero
11
+ * tokens spent asking a model.
12
+ *
13
+ * Contract:
14
+ * - READ-ONLY. The transcript is never modified.
15
+ * - FAIL-SOFT. A missing / oversized / corrupt transcript yields
16
+ * `available: false` plus a machine-readable `reason`. Never throws,
17
+ * never blocks a workflow, never exits non-zero on its own.
18
+ * - NO CONTENT. The envelope carries tool names, short command/path keys
19
+ * and byte counts — never the tool result text itself (dumping it would
20
+ * re-create the very problem this command measures).
21
+ * - BOUNDED MEMORY. The transcript can be tens of MB; it is streamed in
22
+ * fixed-size chunks with a carried partial line, never read whole.
23
+ *
24
+ * Grouping key = `(tool name, short input key)`. The key is a *stable
25
+ * summary* of the tool input — the Bash command line, the file path tail, the
26
+ * grep pattern — so "4 × the same 40KB reindex dump" collapses into ONE row
27
+ * with `count: 4` instead of four anonymous entries.
28
+ */
29
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
30
+ import { getAdapter } from '../ide/ide-registry.js';
31
+ import { detectIdeFromEnv } from './ide-detect.js';
32
+ /** Default number of top entries emitted. */
33
+ export const CONTEXT_AUDIT_DEFAULT_TOP = 15;
34
+ /** Hard ceiling for `--top` — the envelope must stay small by construction. */
35
+ export const CONTEXT_AUDIT_MAX_TOP = 100;
36
+ /** Transcripts larger than this are reported `available:false` (fail-soft). */
37
+ export const CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
38
+ /** Streaming chunk size (bounded memory on multi-MB transcripts). */
39
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
40
+ /** Max characters kept in a group key — keys are labels, not payloads. */
41
+ const KEY_MAX_CHARS = 100;
42
+ /** Clamp a caller-supplied `--top` into the documented range. */
43
+ export function normalizeTopN(value) {
44
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : CONTEXT_AUDIT_DEFAULT_TOP;
45
+ if (n < 1)
46
+ return CONTEXT_AUDIT_DEFAULT_TOP;
47
+ return Math.min(n, CONTEXT_AUDIT_MAX_TOP);
48
+ }
49
+ function clip(text, max) {
50
+ const collapsed = text.replace(/\s+/g, ' ').trim();
51
+ return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
52
+ }
53
+ /** Last `n` path segments — keeps `Read` / `Edit` keys short but identifiable. */
54
+ function tailPath(value, n) {
55
+ const parts = value.split(/[\\/]/).filter((p) => p.length > 0);
56
+ return parts.slice(Math.max(0, parts.length - n)).join('/');
57
+ }
58
+ /**
59
+ * Build the stable group key for one tool call. Unknown tools fall back to a
60
+ * clipped JSON rendering of their input so the group is still recognizable.
61
+ */
62
+ export function contextAuditKey(tool, input) {
63
+ const read = (v) => (typeof v === 'string' ? v : '');
64
+ if (typeof input !== 'object' || input === null)
65
+ return '';
66
+ const i = input;
67
+ switch (tool) {
68
+ case 'Bash':
69
+ return clip(read(i.command), KEY_MAX_CHARS);
70
+ case 'Read':
71
+ case 'Write':
72
+ case 'Edit':
73
+ case 'NotebookEdit':
74
+ return clip(tailPath(read(i.file_path), 2), KEY_MAX_CHARS);
75
+ case 'Grep':
76
+ case 'Glob':
77
+ return clip(`${read(i.pattern)} @ ${read(i.path) || '.'}`, KEY_MAX_CHARS);
78
+ case 'Task':
79
+ case 'Agent':
80
+ return clip(read(i.description) || read(i.subagent_type), 60);
81
+ default: {
82
+ let json;
83
+ try {
84
+ json = JSON.stringify(input) ?? '';
85
+ }
86
+ catch {
87
+ json = '';
88
+ }
89
+ return clip(json, 80);
90
+ }
91
+ }
92
+ }
93
+ /** UTF-8 size of a `tool_result.content` payload (string | array | object). */
94
+ function toolResultBytes(content) {
95
+ if (typeof content === 'string')
96
+ return Buffer.byteLength(content, 'utf8');
97
+ if (Array.isArray(content)) {
98
+ let total = 0;
99
+ for (const part of content) {
100
+ if (typeof part === 'string') {
101
+ total += Buffer.byteLength(part, 'utf8');
102
+ }
103
+ else if (typeof part === 'object' && part !== null) {
104
+ const text = part.text;
105
+ total += typeof text === 'string'
106
+ ? Buffer.byteLength(text, 'utf8')
107
+ : Buffer.byteLength(JSON.stringify(part) ?? '', 'utf8');
108
+ }
109
+ }
110
+ return total;
111
+ }
112
+ if (content === undefined || content === null)
113
+ return 0;
114
+ try {
115
+ return Buffer.byteLength(JSON.stringify(content) ?? '', 'utf8');
116
+ }
117
+ catch {
118
+ return 0;
119
+ }
120
+ }
121
+ function emptyResult(partial) {
122
+ return {
123
+ available: false,
124
+ reason: null,
125
+ transcriptPath: null,
126
+ totalBytes: 0,
127
+ entryCount: 0,
128
+ groupCount: 0,
129
+ topN: CONTEXT_AUDIT_DEFAULT_TOP,
130
+ entries: [],
131
+ ...partial,
132
+ };
133
+ }
134
+ /**
135
+ * Stream the transcript once, folding every tool result into its
136
+ * `(tool, key)` group. Pure bookkeeping — no content is retained.
137
+ */
138
+ function scanTranscript(filePath, topN) {
139
+ const toolUses = new Map();
140
+ const groups = new Map();
141
+ let totalBytes = 0;
142
+ let entryCount = 0;
143
+ const fd = openSync(filePath, 'r');
144
+ try {
145
+ const size = statSync(filePath).size;
146
+ let position = 0;
147
+ let carry = '';
148
+ while (position < size) {
149
+ const readLen = Math.min(SCAN_CHUNK_BYTES, size - position);
150
+ const buf = Buffer.alloc(readLen);
151
+ const bytesRead = readSync(fd, buf, 0, readLen, position);
152
+ if (bytesRead <= 0)
153
+ break;
154
+ position += bytesRead;
155
+ const lines = (carry + buf.toString('utf8', 0, bytesRead)).split('\n');
156
+ // The last element is a partial line (or the trailing empty string).
157
+ carry = lines.pop() ?? '';
158
+ for (const line of lines) {
159
+ if (line.length === 0)
160
+ continue;
161
+ const folded = foldLine(line, toolUses, groups);
162
+ totalBytes += folded.bytes;
163
+ entryCount += folded.count;
164
+ }
165
+ }
166
+ if (carry.length > 0) {
167
+ const folded = foldLine(carry, toolUses, groups);
168
+ totalBytes += folded.bytes;
169
+ entryCount += folded.count;
170
+ }
171
+ }
172
+ finally {
173
+ try {
174
+ closeSync(fd);
175
+ }
176
+ catch {
177
+ /* best-effort close */
178
+ }
179
+ }
180
+ const entries = [...groups.values()]
181
+ .map((g) => ({
182
+ tool: g.tool,
183
+ key: g.key,
184
+ bytes: g.bytes,
185
+ // Percentage in [0, 100], one decimal — see ContextAuditEntry.pctOfTotal.
186
+ pctOfTotal: totalBytes > 0 ? Math.round((g.bytes / totalBytes) * 1000) / 10 : 0,
187
+ count: g.count,
188
+ }))
189
+ .sort((a, b) => b.bytes - a.bytes || a.tool.localeCompare(b.tool) || a.key.localeCompare(b.key));
190
+ return {
191
+ available: true,
192
+ reason: null,
193
+ transcriptPath: filePath,
194
+ totalBytes,
195
+ entryCount,
196
+ groupCount: entries.length,
197
+ topN,
198
+ entries: entries.slice(0, topN),
199
+ };
200
+ }
201
+ /**
202
+ * Fold ONE jsonl line into the running state. Returns the byte/count delta
203
+ * contributed by this line. Corrupt / non-JSON lines are skipped silently —
204
+ * a truncated tail must not fail the audit.
205
+ */
206
+ function foldLine(line, toolUses, groups) {
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(line);
210
+ }
211
+ catch {
212
+ return { bytes: 0, count: 0 };
213
+ }
214
+ if (typeof parsed !== 'object' || parsed === null)
215
+ return { bytes: 0, count: 0 };
216
+ const record = parsed;
217
+ const message = record.message;
218
+ if (typeof message !== 'object' || message === null)
219
+ return { bytes: 0, count: 0 };
220
+ const content = message.content;
221
+ if (!Array.isArray(content))
222
+ return { bytes: 0, count: 0 };
223
+ let bytes = 0;
224
+ let count = 0;
225
+ for (const part of content) {
226
+ if (typeof part !== 'object' || part === null)
227
+ continue;
228
+ const item = part;
229
+ if (item.type === 'tool_use') {
230
+ const id = item.id;
231
+ const name = item.name;
232
+ if (typeof id === 'string' && typeof name === 'string') {
233
+ toolUses.set(id, { name, input: item.input });
234
+ }
235
+ continue;
236
+ }
237
+ if (item.type !== 'tool_result')
238
+ continue;
239
+ const ref = typeof item.tool_use_id === 'string' ? toolUses.get(item.tool_use_id) : undefined;
240
+ const tool = ref?.name ?? 'unknown';
241
+ const key = ref === undefined ? '' : contextAuditKey(tool, ref.input);
242
+ const size = toolResultBytes(item.content);
243
+ const groupKey = `${tool}${key}`;
244
+ const group = groups.get(groupKey);
245
+ if (group === undefined) {
246
+ groups.set(groupKey, { tool, key, bytes: size, count: 1 });
247
+ }
248
+ else {
249
+ group.bytes += size;
250
+ group.count += 1;
251
+ }
252
+ bytes += size;
253
+ count += 1;
254
+ }
255
+ return { bytes, count };
256
+ }
257
+ /**
258
+ * Audit the CURRENT session's transcript. Never throws.
259
+ *
260
+ * Unavailability reasons (all return `available: false`, exit code stays 0):
261
+ * - `no-outer-session-id` — the peaks session has no bound outer id
262
+ * - `transcript-locator-unavailable` — the active IDE adapter does not
263
+ * declare `compact.resolveTranscriptPath`
264
+ * - `transcript-not-found` — the adapter locator returned null
265
+ * - `transcript-too-large` — above `CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES`
266
+ * - `transcript-unreadable`— stat/open failed
267
+ * - `audit-failed` — any unexpected internal error
268
+ */
269
+ export function auditContext(input = {}) {
270
+ const topN = normalizeTopN(input.topN);
271
+ const explicit = input.transcriptPath;
272
+ let transcriptPath = null;
273
+ if (typeof explicit === 'string' && explicit.length > 0) {
274
+ transcriptPath = explicit;
275
+ }
276
+ else {
277
+ const outerSessionId = input.outerSessionId;
278
+ if (typeof outerSessionId !== 'string' || outerSessionId.length === 0) {
279
+ return emptyResult({ reason: 'no-outer-session-id', topN });
280
+ }
281
+ // Vendor-neutral: the adapter owns the on-disk layout. Mirrors
282
+ // `readContextPercent`'s narrowing of the detected kind to a
283
+ // registered adapter id ('unknown' → claude-code default). Both the
284
+ // registry lookup and the locator call are guarded — an unregistered
285
+ // detected id (e.g. an IDE without a peaks adapter yet) or an adapter
286
+ // bug must degrade, never throw.
287
+ let transcriptPathOrNull;
288
+ try {
289
+ const detected = detectIdeFromEnv(input.env ?? process.env);
290
+ const ideId = (detected === 'unknown' ? 'claude-code' : detected);
291
+ const locate = getAdapter(ideId).compact?.resolveTranscriptPath;
292
+ if (locate === undefined) {
293
+ return emptyResult({ reason: 'transcript-locator-unavailable', topN });
294
+ }
295
+ transcriptPathOrNull = locate(outerSessionId);
296
+ }
297
+ catch {
298
+ return emptyResult({ reason: 'transcript-locator-unavailable', topN });
299
+ }
300
+ if (transcriptPathOrNull === null) {
301
+ return emptyResult({ reason: 'transcript-not-found', topN });
302
+ }
303
+ transcriptPath = transcriptPathOrNull;
304
+ }
305
+ const maxBytes = typeof input.maxTranscriptBytes === 'number' && Number.isFinite(input.maxTranscriptBytes) && input.maxTranscriptBytes >= 0
306
+ ? input.maxTranscriptBytes
307
+ : CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES;
308
+ try {
309
+ const size = statSync(transcriptPath).size;
310
+ if (size > maxBytes) {
311
+ return emptyResult({ reason: 'transcript-too-large', transcriptPath, topN });
312
+ }
313
+ return scanTranscript(transcriptPath, topN);
314
+ }
315
+ catch (err) {
316
+ const code = err.code;
317
+ const reason = code === 'ENOENT' ? 'transcript-not-found'
318
+ : code === 'EACCES' || code === 'EPERM' ? 'transcript-unreadable'
319
+ : 'audit-failed';
320
+ return emptyResult({ reason, transcriptPath, topN });
321
+ }
322
+ }
@@ -13,8 +13,8 @@ export declare const ContextJsonSchema: z.ZodObject<{
13
13
  path: z.ZodString;
14
14
  kind: z.ZodEnum<{
15
15
  doc: "doc";
16
- config: "config";
17
16
  source: "source";
17
+ config: "config";
18
18
  test: "test";
19
19
  }>;
20
20
  lines: z.ZodNumber;