peaks-loop 4.0.46 → 4.0.47

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 (130) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-runtime-commands.d.ts +22 -0
  5. package/dist/cli/commands/code-runtime-commands.js +99 -16
  6. package/dist/cli/commands/compact-command.js +129 -1
  7. package/dist/cli/commands/container-commands.js +3 -3
  8. package/dist/cli/commands/core/skill-command.js +45 -10
  9. package/dist/cli/commands/cron-commands.js +2 -1
  10. package/dist/cli/commands/e2e-verify.js +3 -3
  11. package/dist/cli/commands/governance-classify-contract-commands.js +1 -0
  12. package/dist/cli/commands/hooks-commands.js +10 -1
  13. package/dist/cli/commands/loop-commands.js +1 -0
  14. package/dist/cli/commands/playwright-commands.js +2 -1
  15. package/dist/cli/commands/reinject-command.d.ts +72 -0
  16. package/dist/cli/commands/reinject-command.js +174 -0
  17. package/dist/cli/commands/request-commands.js +6 -3
  18. package/dist/cli/commands/shadcn-commands.js +1 -0
  19. package/dist/cli/commands/test-commands.js +2 -1
  20. package/dist/cli/commands/vm-commands.js +7 -7
  21. package/dist/cli/commands/workspace/init-command.js +24 -2
  22. package/dist/cli/commands/worktree-lease-commands.js +4 -4
  23. package/dist/cli/index.js +49 -2
  24. package/dist/cli/program.js +5 -0
  25. package/dist/hooks/pre-tool-use-sub-agent.js +1 -1
  26. package/dist/services/adapter/adapter-registry.js +1 -1
  27. package/dist/services/artifacts/artifact-service.js +1 -1
  28. package/dist/services/capability-guard-runner/contracts/J01.js +2 -1
  29. package/dist/services/capability-guard-runner/contracts/J02.js +3 -3
  30. package/dist/services/capability-guard-runner/contracts/J04.js +4 -2
  31. package/dist/services/capability-guard-runner/contracts/J07.js +2 -1
  32. package/dist/services/code/auto-compact-lifecycle.d.ts +11 -1
  33. package/dist/services/code/auto-compact-lifecycle.js +11 -4
  34. package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
  35. package/dist/services/code/auto-compact-orchestrator.js +153 -32
  36. package/dist/services/code/orchestrator-can-do.d.ts +4 -2
  37. package/dist/services/code/orchestrator-can-do.js +37 -5
  38. package/dist/services/codegraph/codegraph-exclude-reconciler.js +2 -1
  39. package/dist/services/codegraph/codegraph-process-runner.js +3 -2
  40. package/dist/services/compact/request-transition-hook.js +5 -2
  41. package/dist/services/compact-history/compact-history-service.d.ts +61 -0
  42. package/dist/services/compact-history/compact-history-service.js +49 -0
  43. package/dist/services/config/config-safety.d.ts +52 -0
  44. package/dist/services/config/config-safety.js +75 -1
  45. package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
  46. package/dist/services/context/auto-compact-dispatcher.js +113 -40
  47. package/dist/services/context/auto-compact-reader.d.ts +68 -28
  48. package/dist/services/context/auto-compact-reader.js +155 -1
  49. package/dist/services/context/auto-compact-types.d.ts +89 -12
  50. package/dist/services/context/auto-compact-types.js +16 -32
  51. package/dist/services/context/harness-window-config.d.ts +412 -0
  52. package/dist/services/context/harness-window-config.js +607 -0
  53. package/dist/services/context/main-session-monitor.d.ts +27 -0
  54. package/dist/services/context/main-session-monitor.js +32 -1
  55. package/dist/services/context/post-compact-reinjection.d.ts +221 -0
  56. package/dist/services/context/post-compact-reinjection.js +491 -0
  57. package/dist/services/dispatch/merge-back-runner.js +5 -5
  58. package/dist/services/dispatch/service-shutdown.js +3 -3
  59. package/dist/services/doc/doc-generator.js +2 -1
  60. package/dist/services/env/shell-probe.js +1 -1
  61. package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
  62. package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
  63. package/dist/services/hooks/auto-compact-hook-install.js +8 -0
  64. package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
  65. package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
  66. package/dist/services/ide/ide-registry.d.ts +12 -0
  67. package/dist/services/ide/ide-registry.js +14 -0
  68. package/dist/services/ide/ide-types.d.ts +59 -0
  69. package/dist/services/lint/detect-eslint.js +2 -2
  70. package/dist/services/lint/eslint-runner.js +3 -1
  71. package/dist/services/loop/evaluator-dispatcher.js +2 -1
  72. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
  73. package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
  74. package/dist/services/memory/project-memory-service/store/paths.js +15 -6
  75. package/dist/services/prd/best-practice-auto-trigger.js +1 -0
  76. package/dist/services/release/version-precheck-service.d.ts +2 -1
  77. package/dist/services/release/version-precheck-service.js +82 -12
  78. package/dist/services/runtime/vendor-adapter.d.ts +29 -4
  79. package/dist/services/runtime/vendors/claude-code.js +1 -1
  80. package/dist/services/runtime/vendors/codex.js +1 -1
  81. package/dist/services/runtime/vendors/copilot.js +1 -1
  82. package/dist/services/sc/sc-service.js +1 -1
  83. package/dist/services/scan/diff-scope-service.js +2 -2
  84. package/dist/services/scan/file-size-scan.js +2 -2
  85. package/dist/services/scan/orphan-service.js +2 -1
  86. package/dist/services/scan/type-sanity-service.js +2 -2
  87. package/dist/services/skillhub/tar-runtime.js +1 -0
  88. package/dist/services/skills/hooks-codegate-superpowers.d.ts +8 -0
  89. package/dist/services/skills/hooks-codegate-superpowers.js +40 -3
  90. package/dist/services/skills/hooks-settings-service.d.ts +12 -0
  91. package/dist/services/skills/hooks-settings-service.js +77 -10
  92. package/dist/services/skills/session-start-hook-constants.d.ts +41 -0
  93. package/dist/services/skills/session-start-hook-constants.js +41 -0
  94. package/dist/services/skills/skill-presence-service.js +9 -0
  95. package/dist/services/slice/slice-check-service.js +2 -1
  96. package/dist/services/slice/slice-decompose-runners.js +2 -1
  97. package/dist/services/upgrade/upgrade-service.js +1 -0
  98. package/dist/services/workflow/workflow-skip-service.js +2 -1
  99. package/dist/services/workspace/migrate-service.js +1 -1
  100. package/dist/services/workspace/workspace-claude-settings-materializer.js +51 -7
  101. package/dist/services/workspace/workspace-service.js +8 -0
  102. package/dist/services/worktree/host-worktree-reconciler.js +1 -0
  103. package/dist/services/worktree/long-path-cleanup.js +3 -2
  104. package/dist/shared/process.js +1 -1
  105. package/package.json +5 -5
  106. package/scripts/install-skills.mjs +1 -0
  107. package/scripts/watch.mjs +3 -1
  108. package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
  109. package/skills/bee/peaks-prd/SKILL.md +1 -1
  110. package/skills/bee/peaks-qa/SKILL.md +2 -2
  111. package/skills/bee/peaks-rd/SKILL.md +2 -2
  112. package/skills/bee/peaks-reviewer/SKILL.md +1 -1
  113. package/skills/bee/peaks-sc/SKILL.md +1 -1
  114. package/skills/bee/peaks-security-audit/SKILL.md +1 -1
  115. package/skills/bee/peaks-txt/SKILL.md +1 -1
  116. package/skills/bee/peaks-ui/SKILL.md +1 -1
  117. package/skills/peaks-audit/SKILL.md +1 -1
  118. package/skills/peaks-code/SKILL.md +2 -2
  119. package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
  120. package/skills/peaks-content/SKILL.md +1 -1
  121. package/skills/peaks-doctor/SKILL.md +1 -1
  122. package/skills/peaks-final-review/SKILL.md +1 -1
  123. package/skills/peaks-ide/SKILL.md +1 -1
  124. package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
  125. package/skills/peaks-resume/SKILL.md +1 -1
  126. package/skills/peaks-slice-decompose/SKILL.md +1 -1
  127. package/skills/peaks-solo/SKILL.md +1 -1
  128. package/skills/peaks-sop/SKILL.md +1 -1
  129. package/skills/peaks-status/SKILL.md +1 -1
  130. package/skills/peaks-test/SKILL.md +1 -1
@@ -47,11 +47,16 @@ export declare const CONTEXT_WINDOW_TOKENS_ENV_VAR = "PEAKS_CONTEXT_WINDOW_TOKEN
47
47
  /**
48
48
  * Which layer produced a resolved context window:
49
49
  * - `env-override` — `PEAKS_CONTEXT_WINDOW_TOKENS`
50
+ * - `harness-env` — the window peaks-loop itself wrote into the
51
+ * harness's machine-local settings
52
+ * (`CLAUDE_CODE_AUTO_COMPACT_WINDOW`, declared by
53
+ * the adapter as `autoCompactWindowEnvVar`); see
54
+ * `harness-window-config.ts`
50
55
  * - `config` — `context.windowTokens` (`peaks config set`)
51
56
  * - `model-heuristic` — `[1M]` suffix / `ONE_MILLION_CONTEXT_MODELS`
52
57
  * - `default` — 200K safe default
53
58
  */
54
- export type ContextWindowSource = 'env-override' | 'config' | 'model-heuristic' | 'default';
59
+ export type ContextWindowSource = 'env-override' | 'harness-env' | 'config' | 'model-heuristic' | 'default';
55
60
  export interface ContextWindowResolution {
56
61
  readonly tokens: number;
57
62
  readonly source: ContextWindowSource;
@@ -61,9 +66,78 @@ export interface ContextWindowOverrides {
61
66
  readonly env?: NodeJS.ProcessEnv | undefined;
62
67
  /** Raw `context.windowTokens` value (unvalidated — validated here). */
63
68
  readonly configWindowTokens?: unknown;
69
+ /**
70
+ * The window peaks-loop itself configured for the harness — raw value read
71
+ * from the harness's machine-local settings `env` block (or the process env
72
+ * the harness populated from it). Resolved by the caller
73
+ * (`auto-compact-reader.ts` via `harness-window-config.ts`), NOT here: the
74
+ * settings path and the key name are per-IDE declarations.
75
+ *
76
+ * Slice 2026-09-13-auto-compact-trigger-ownership: this layer is what makes
77
+ * the ratio peaks-loop computes and the window the harness compacts against
78
+ * THE SAME NUMBER. Without it they are two independent resolutions that can
79
+ * drift 5× apart on a 1M-window model the model-name heuristic misses.
80
+ */
81
+ readonly harnessWindowTokens?: unknown;
82
+ /**
83
+ * Provenance of `harnessWindowTokens`: true when peaks-loop wrote that value
84
+ * itself, false/omitted when a human set it. Only a peaks-written value may
85
+ * be overruled by the late 1M rescue — see `resolveContextWindowTokens`.
86
+ */
87
+ readonly harnessWindowPeakWritten?: boolean;
64
88
  /** Warning sink for an invalid override (defaults to `console.warn`). */
65
89
  readonly onInvalidOverride?: ((message: string) => void) | undefined;
90
+ /**
91
+ * Warning sink for the E2 notice: a HUMAN pin (`env-override` / `config`)
92
+ * larger than peaks-loop's own model-window estimate. Deliberately separate
93
+ * from `onInvalidOverride` — that sink means "your value could not be read",
94
+ * this one means "your value was read, and peaks-loop cannot promise the
95
+ * harness will use it". Two meanings, two sinks. Defaults to `console.warn`.
96
+ *
97
+ * See `describeWindowAboveModelEstimate` for what the notice does and does
98
+ * not claim, and why it is a notice rather than a refusal.
99
+ */
100
+ readonly onAboveModelEstimate?: ((message: string) => void) | undefined;
66
101
  }
102
+ /**
103
+ * E2 (rid 2026-09-13-defects-e) — the notice for a pin larger than
104
+ * peaks-loop's model-window estimate. `null` when there is nothing to say.
105
+ *
106
+ * WHY THIS EXISTS. The harness does not take its auto-compact window on faith:
107
+ * it reduces the configured value with `Math.min(native, override)`, where
108
+ * `native` is the model's own context size. A pin ABOVE `native` is therefore
109
+ * accepted by peaks-loop, written to the harness's settings, and silently
110
+ * reduced by the harness — so peaks-loop's ratio divides by a window the
111
+ * harness is not compacting on. That is the A1 drift (two independent
112
+ * resolutions, one number meaning two things), arriving from the other side
113
+ * from E1: E1 is a value the harness refuses, E2 a value it quietly lowers.
114
+ *
115
+ * CAN WE READ THE NATIVE WINDOW? No, and the notice does not pretend otherwise.
116
+ * There is no API for it and no environment variable that carries it; the only
117
+ * local knowledge is `modelContextWindowTokens`'s NAME HEURISTIC (200_000, or
118
+ * 1_000_000 when the id says `1m` or matches the allowlist) — the very layer
119
+ * this slice demoted, because it is wrong for precisely the proxied /
120
+ * third-party models the pin exists for. It is used here ONLY as a disagreement
121
+ * DETECTOR, never as an authority, and the uncertainty that buys is one-sided:
122
+ * this notice means "peaks-loop's best guess of your model is smaller than the
123
+ * window you pinned", which a genuinely larger model ALSO produces. It cannot
124
+ * mean "the harness will definitely cap you" — only the harness knows that.
125
+ *
126
+ * WHY A NOTICE AND NOT A REFUSAL. The pin is the documented escape hatch for a
127
+ * model id the heuristic cannot see, so refusing would delete the feature. The
128
+ * notice keeps it and states the risk.
129
+ *
130
+ * WHY ONLY THE HUMAN PINS. The `harness-env` layer is a value peaks-loop FOUND
131
+ * in the harness's own file, not one this call is setting; its disagreements are
132
+ * already reported by the writer (`harnessWindowSyncWarning`), and warning here
133
+ * would fire on every probe of a project whose file legitimately says 1000000.
134
+ */
135
+ export declare function describeWindowAboveModelEstimate(input: {
136
+ readonly model: string;
137
+ readonly tokens: number;
138
+ /** Human-readable name of the pin, e.g. `PEAKS_CONTEXT_WINDOW_TOKENS="500000"`. */
139
+ readonly pin: string;
140
+ }): string | null;
67
141
  /**
68
142
  * Parse an explicit context-window override. Accepts a positive finite
69
143
  * integer only (number, or a numeric string so an env var works); anything
@@ -76,11 +150,41 @@ export declare function parseContextWindowOverride(raw: unknown): number | null;
76
150
  * (first hit wins):
77
151
  * 1. env `PEAKS_CONTEXT_WINDOW_TOKENS`
78
152
  * 2. config `context.windowTokens`
79
- * 3. model-name heuristic (`modelContextWindowTokens`)
80
- * 4. `DEFAULT_CONTEXT_WINDOW_TOKENS` (200_000)
153
+ * 3. `harnessWindowTokens` — the window peaks-loop configured for the
154
+ * harness (`autoCompactWindowEnvVar`)
155
+ * 4. model-name heuristic (`modelContextWindowTokens`)
156
+ * 5. `DEFAULT_CONTEXT_WINDOW_TOKENS` (200_000)
81
157
  *
82
158
  * An invalid explicit override is ignored with a warning and falls through
83
159
  * to the next layer — a typo must never crash or silently win the probe.
160
+ *
161
+ * Slice 2026-09-13-auto-compact-trigger-ownership — why layer 3 exists:
162
+ * The hard constraint is that the window peaks-loop divides by must BE the
163
+ * window it configured for the harness. Layer 3 is peaks-loop's own output
164
+ * read back from the harness's settings file, so the two sides reference one
165
+ * artifact rather than two independent resolutions. It sits ABOVE the model
166
+ * heuristic because a heuristic contradicting the number both sides already
167
+ * use is precisely how the 5× drift arose (a 1M-window model measured
168
+ * against a 200K guess).
169
+ *
170
+ * Why it sits BELOW the two explicit pins. Both are human declarations of
171
+ * intent, and a pin that loses to a value peaks-loop wrote earlier is a
172
+ * silently ignored setting: the user changes `context.windowTokens`, the
173
+ * stale harness key shadows it, and nothing appears to happen. Conflict is
174
+ * instead resolved by PROPAGATION — the probe that resolves the pin also
175
+ * syncs it into the harness (`syncHarnessWindowForProject`), so peaks-loop's
176
+ * ratio and the harness's window are back on one number before that command
177
+ * returns. The conflict is therefore transient and self-healing rather than
178
+ * either silent shadowing or permanent drift.
179
+ *
180
+ * Why layer 3 is not simply authoritative (the ratchet). Reading back a value
181
+ * peaks-loop wrote makes peaks-loop trust its own earlier resolution, so a
182
+ * first-time mis-resolution (an unrecognised model defaulting to 200_000)
183
+ * would be written to disk and then believed forever: the ratio stays
184
+ * saturated at 1.0 while the session grows past the pin, and the correction
185
+ * that exists for exactly that case — the late 1M rescue — was disabled for
186
+ * this layer. Self-locking. See `resolveContextWindowTokens` for how the
187
+ * rescue is let back in without letting it overwrite a human's own setting.
84
188
  */
85
189
  export declare function resolveContextWindow(model: string, overrides?: ContextWindowOverrides): ContextWindowResolution;
86
190
  export declare const CLAUDE_CODE_ADAPTER: IdeAdapter;
@@ -200,6 +200,50 @@ export function modelContextWindowTokens(model) {
200
200
  * machine-wide `peaks config set --key context.windowTokens` twin.
201
201
  */
202
202
  export const CONTEXT_WINDOW_TOKENS_ENV_VAR = 'PEAKS_CONTEXT_WINDOW_TOKENS';
203
+ /**
204
+ * E2 (rid 2026-09-13-defects-e) — the notice for a pin larger than
205
+ * peaks-loop's model-window estimate. `null` when there is nothing to say.
206
+ *
207
+ * WHY THIS EXISTS. The harness does not take its auto-compact window on faith:
208
+ * it reduces the configured value with `Math.min(native, override)`, where
209
+ * `native` is the model's own context size. A pin ABOVE `native` is therefore
210
+ * accepted by peaks-loop, written to the harness's settings, and silently
211
+ * reduced by the harness — so peaks-loop's ratio divides by a window the
212
+ * harness is not compacting on. That is the A1 drift (two independent
213
+ * resolutions, one number meaning two things), arriving from the other side
214
+ * from E1: E1 is a value the harness refuses, E2 a value it quietly lowers.
215
+ *
216
+ * CAN WE READ THE NATIVE WINDOW? No, and the notice does not pretend otherwise.
217
+ * There is no API for it and no environment variable that carries it; the only
218
+ * local knowledge is `modelContextWindowTokens`'s NAME HEURISTIC (200_000, or
219
+ * 1_000_000 when the id says `1m` or matches the allowlist) — the very layer
220
+ * this slice demoted, because it is wrong for precisely the proxied /
221
+ * third-party models the pin exists for. It is used here ONLY as a disagreement
222
+ * DETECTOR, never as an authority, and the uncertainty that buys is one-sided:
223
+ * this notice means "peaks-loop's best guess of your model is smaller than the
224
+ * window you pinned", which a genuinely larger model ALSO produces. It cannot
225
+ * mean "the harness will definitely cap you" — only the harness knows that.
226
+ *
227
+ * WHY A NOTICE AND NOT A REFUSAL. The pin is the documented escape hatch for a
228
+ * model id the heuristic cannot see, so refusing would delete the feature. The
229
+ * notice keeps it and states the risk.
230
+ *
231
+ * WHY ONLY THE HUMAN PINS. The `harness-env` layer is a value peaks-loop FOUND
232
+ * in the harness's own file, not one this call is setting; its disagreements are
233
+ * already reported by the writer (`harnessWindowSyncWarning`), and warning here
234
+ * would fire on every probe of a project whose file legitimately says 1000000.
235
+ */
236
+ export function describeWindowAboveModelEstimate(input) {
237
+ const estimate = modelContextWindowTokens(input.model);
238
+ if (input.tokens <= estimate)
239
+ return null;
240
+ return (`[peaks] ${input.pin} is larger than peaks-loop's model-window estimate for ` +
241
+ `"${input.model}" (${estimate} tokens). peaks-loop cannot read a model's native context window — this ` +
242
+ `estimate is a name heuristic and may be wrong — and the harness caps its auto-compact window at that ` +
243
+ `native size, so if the estimate is right the harness will compact at ${estimate} while this ratio ` +
244
+ `divides by ${input.tokens}. If your model really does have the larger window this is expected; ` +
245
+ `otherwise set the value to the model's real window so the two stay in step.`);
246
+ }
203
247
  /**
204
248
  * Parse an explicit context-window override. Accepts a positive finite
205
249
  * integer only (number, or a numeric string so an env var works); anything
@@ -219,27 +263,82 @@ export function parseContextWindowOverride(raw) {
219
263
  * (first hit wins):
220
264
  * 1. env `PEAKS_CONTEXT_WINDOW_TOKENS`
221
265
  * 2. config `context.windowTokens`
222
- * 3. model-name heuristic (`modelContextWindowTokens`)
223
- * 4. `DEFAULT_CONTEXT_WINDOW_TOKENS` (200_000)
266
+ * 3. `harnessWindowTokens` — the window peaks-loop configured for the
267
+ * harness (`autoCompactWindowEnvVar`)
268
+ * 4. model-name heuristic (`modelContextWindowTokens`)
269
+ * 5. `DEFAULT_CONTEXT_WINDOW_TOKENS` (200_000)
224
270
  *
225
271
  * An invalid explicit override is ignored with a warning and falls through
226
272
  * to the next layer — a typo must never crash or silently win the probe.
273
+ *
274
+ * Slice 2026-09-13-auto-compact-trigger-ownership — why layer 3 exists:
275
+ * The hard constraint is that the window peaks-loop divides by must BE the
276
+ * window it configured for the harness. Layer 3 is peaks-loop's own output
277
+ * read back from the harness's settings file, so the two sides reference one
278
+ * artifact rather than two independent resolutions. It sits ABOVE the model
279
+ * heuristic because a heuristic contradicting the number both sides already
280
+ * use is precisely how the 5× drift arose (a 1M-window model measured
281
+ * against a 200K guess).
282
+ *
283
+ * Why it sits BELOW the two explicit pins. Both are human declarations of
284
+ * intent, and a pin that loses to a value peaks-loop wrote earlier is a
285
+ * silently ignored setting: the user changes `context.windowTokens`, the
286
+ * stale harness key shadows it, and nothing appears to happen. Conflict is
287
+ * instead resolved by PROPAGATION — the probe that resolves the pin also
288
+ * syncs it into the harness (`syncHarnessWindowForProject`), so peaks-loop's
289
+ * ratio and the harness's window are back on one number before that command
290
+ * returns. The conflict is therefore transient and self-healing rather than
291
+ * either silent shadowing or permanent drift.
292
+ *
293
+ * Why layer 3 is not simply authoritative (the ratchet). Reading back a value
294
+ * peaks-loop wrote makes peaks-loop trust its own earlier resolution, so a
295
+ * first-time mis-resolution (an unrecognised model defaulting to 200_000)
296
+ * would be written to disk and then believed forever: the ratio stays
297
+ * saturated at 1.0 while the session grows past the pin, and the correction
298
+ * that exists for exactly that case — the late 1M rescue — was disabled for
299
+ * this layer. Self-locking. See `resolveContextWindowTokens` for how the
300
+ * rescue is let back in without letting it overwrite a human's own setting.
227
301
  */
228
302
  export function resolveContextWindow(model, overrides = {}) {
229
303
  const warn = overrides.onInvalidOverride ?? ((message) => console.warn(message));
304
+ // E2: the second sink, for a value that IS valid but that the harness may
305
+ // reduce. Kept separate so neither notice can be mistaken for the other.
306
+ const warnAboveEstimate = overrides.onAboveModelEstimate ?? ((message) => console.warn(message));
230
307
  const envRaw = overrides.env?.[CONTEXT_WINDOW_TOKENS_ENV_VAR];
231
308
  if (envRaw !== undefined) {
232
309
  const parsed = parseContextWindowOverride(envRaw);
233
- if (parsed !== null)
310
+ if (parsed !== null) {
311
+ const above = describeWindowAboveModelEstimate({
312
+ model,
313
+ tokens: parsed,
314
+ pin: `${CONTEXT_WINDOW_TOKENS_ENV_VAR}="${String(envRaw)}"`
315
+ });
316
+ if (above !== null)
317
+ warnAboveEstimate(above);
234
318
  return { tokens: parsed, source: 'env-override' };
319
+ }
235
320
  warn(`[peaks] ${CONTEXT_WINDOW_TOKENS_ENV_VAR}="${String(envRaw)}" is not a positive integer — ignoring the override`);
236
321
  }
237
322
  if (overrides.configWindowTokens !== undefined) {
238
323
  const parsed = parseContextWindowOverride(overrides.configWindowTokens);
239
- if (parsed !== null)
324
+ if (parsed !== null) {
325
+ const above = describeWindowAboveModelEstimate({
326
+ model,
327
+ tokens: parsed,
328
+ pin: `config context.windowTokens=${parsed}`
329
+ });
330
+ if (above !== null)
331
+ warnAboveEstimate(above);
240
332
  return { tokens: parsed, source: 'config' };
333
+ }
241
334
  warn(`[peaks] config context.windowTokens=${JSON.stringify(overrides.configWindowTokens)} is not a positive integer — ignoring the override`);
242
335
  }
336
+ if (overrides.harnessWindowTokens !== undefined) {
337
+ const parsed = parseContextWindowOverride(overrides.harnessWindowTokens);
338
+ if (parsed !== null)
339
+ return { tokens: parsed, source: 'harness-env' };
340
+ warn(`[peaks] harness auto-compact window ${JSON.stringify(overrides.harnessWindowTokens)} is not a positive integer — ignoring the override`);
341
+ }
243
342
  const heuristic = modelContextWindowTokens(model);
244
343
  return heuristic === DEFAULT_CONTEXT_WINDOW_TOKENS
245
344
  ? { tokens: heuristic, source: 'default' }
@@ -354,11 +453,37 @@ function findLatestTranscriptUsage(filePath) {
354
453
  * when the observed token count contradicts the heuristic window (tokens
355
454
  * exceed it), the model must be ≥1M, so bump to the 1M window (the late
356
455
  * rescue; it keeps the heuristic source tag, only the tokens change).
456
+ *
457
+ * `env-override` and `config` are in the no-bump list because a human pinned
458
+ * them: a pin the probe silently overrules is a setting that does not work.
459
+ *
460
+ * `harness-env` is the interesting one, and is decided by PROVENANCE:
461
+ *
462
+ * - peaks-written (`harnessWindowPeakWritten === true`): may be bumped. This
463
+ * is the ratchet fix. The value is peaks-loop's own earlier resolution, so
464
+ * believing it forever makes a first-time mis-resolution PERMANENT — a
465
+ * 200_000 default written to disk, re-read as the window, and exempt from
466
+ * the very rescue that exists to correct it, leaving the ratio saturated at
467
+ * 1.0 while the session grows past the pin. Evidence must be able to
468
+ * overrule peaks-loop's own output.
469
+ * - human-set (false/omitted): NOT bumpable. Here the key is a person's
470
+ * explicit declaration — the documented Claude Code variable, hand-edited.
471
+ * Overruling it would not be self-correction, it would be peaks-loop
472
+ * silently rewriting a setting the user chose (and persisting the rewrite).
473
+ *
474
+ * Both branches keep the single-source rule: the resolution this function
475
+ * returns becomes `probe.capacityTokens`, and the caller syncs exactly that
476
+ * back into the harness key (`syncHarnessWindowForProject`). When the rescue
477
+ * fires, the key is refreshed to the bumped number in the same command, so
478
+ * peaks-loop's ratio and the harness window are one number again — the rescue
479
+ * never leaves the two sides apart.
357
480
  */
358
481
  function resolveContextWindowTokens(model, contextTokens, overrides = {}) {
359
482
  const resolved = resolveContextWindow(model, overrides);
360
483
  if (resolved.source === 'env-override' || resolved.source === 'config')
361
484
  return resolved;
485
+ if (resolved.source === 'harness-env' && overrides.harnessWindowPeakWritten !== true)
486
+ return resolved;
362
487
  return contextTokens > resolved.tokens
363
488
  ? { tokens: ONE_MILLION_CONTEXT_TOKENS, source: resolved.source }
364
489
  : resolved;
@@ -376,8 +501,9 @@ function resolveContextWindowTokens(model, contextTokens, overrides = {}) {
376
501
  * Window model resolution is env-first: when `envModel` is present, its id
377
502
  * (which Claude Code stamps with the `[1M]` / `[200K]` suffix) drives the
378
503
  * window; otherwise the transcript `message.model` is used. Explicit
379
- * overrides (env `PEAKS_CONTEXT_WINDOW_TOKENS` / config `context.windowTokens`)
380
- * sit above both and are reported via `capacitySource`.
504
+ * overrides (env `PEAKS_CONTEXT_WINDOW_TOKENS` / the harness key peaks-loop
505
+ * wrote / config `context.windowTokens`) sit above both and are reported via
506
+ * `capacitySource`.
381
507
  */
382
508
  function readClaudeTranscriptEstimate(outerSessionId, envModel, overrides = {}) {
383
509
  const projectsDir = join(homedir(), '.claude', 'projects');
@@ -426,7 +552,15 @@ function readContextPercentFallback(input) {
426
552
  const envModel = resolveClaudeModelFromEnv(input.env);
427
553
  const estimate = readClaudeTranscriptEstimate(input.outerSessionId, envModel, {
428
554
  env: input.env,
429
- configWindowTokens: input.configWindowTokens
555
+ configWindowTokens: input.configWindowTokens,
556
+ // Slice 2026-09-13-auto-compact-trigger-ownership: the window
557
+ // peaks-loop configured for the harness outranks config + heuristic
558
+ // (see `resolveContextWindow`). The generic reader resolved it from the
559
+ // adapter's own declarations — this module never names the key.
560
+ ...(input.harnessWindowTokens !== undefined ? { harnessWindowTokens: input.harnessWindowTokens } : {}),
561
+ // ...and whether that value is peaks-loop's own output (bumpable) or a
562
+ // human's pin (not) — see `resolveContextWindowTokens`.
563
+ ...(input.harnessWindowPeakWritten !== undefined ? { harnessWindowPeakWritten: input.harnessWindowPeakWritten } : {})
430
564
  });
431
565
  if (estimate !== null) {
432
566
  return {
@@ -448,6 +582,11 @@ export const CLAUDE_CODE_ADAPTER = {
448
582
  settings: {
449
583
  dirName: '.claude',
450
584
  settingsFileName: 'settings.json',
585
+ // The machine-local layer Claude Code merges on top of settings.json.
586
+ // Declared here so the auto-compact `ide-native` hook writer reads the
587
+ // path off the adapter instead of assuming a Claude-specific filename —
588
+ // see `IdeSettingsLocation.localSettingsFileName`.
589
+ localSettingsFileName: 'settings.local.json',
451
590
  resolveSettingsFile: (scope, projectRoot) => {
452
591
  const root = scope === 'global' ? homedir() : resolve(projectRoot ?? homedir());
453
592
  return join(root, '.claude', 'settings.json');
@@ -497,6 +636,14 @@ export const CLAUDE_CODE_ADAPTER = {
497
636
  compactCommand: 'claude --compact',
498
637
  compactPathway: 'ide-native',
499
638
  postCompactDetectCommand: 'peaks code auto-compact --json',
639
+ // Slice 2026-09-13-auto-compact-trigger-ownership: the key Claude Code
640
+ // reads its auto-compact WINDOW from, in the machine-local `env` block.
641
+ // Claude Code documents it as taking precedence "over the command, the
642
+ // flag, and the setting", and as accepting the plain token count only
643
+ // (no `500k` suffix). peaks-loop writes the window it computes the ratio
644
+ // against here so the two sides cannot drift; see
645
+ // `harness-window-config.ts`.
646
+ autoCompactWindowEnvVar: 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
500
647
  readContextPercentFallback,
501
648
  // Slice 2026-09-10-context-audit-and-discipline (Slice A): the vendor
502
649
  // layout knowledge (`~/.claude/projects/**/<outerSessionId>.jsonl`)
@@ -1,6 +1,18 @@
1
1
  import type { IdeAdapter, IdeId } from './ide-types.js';
2
2
  /** Get the adapter for a given IDE id. Throws on unsupported IDE. */
3
3
  export declare function getAdapter(ide: IdeId): IdeAdapter;
4
+ /**
5
+ * Non-throwing twin of `getAdapter`. Returns `undefined` for an id with
6
+ * no registered adapter instead of raising.
7
+ *
8
+ * For callers that hold an IDE-shaped string which is NOT guaranteed to
9
+ * be an `IdeId`: `detectIdeFromEnv` returns `IdeKind` (`'claude-code' |
10
+ * 'trae' | 'opencode' | 'unknown'`), and `'opencode'` has no adapter
11
+ * registered. Casting to `IdeId` and calling `getAdapter` would throw
12
+ * inside a pure formatting/threshold path — the caller wants "no
13
+ * adapter → fall back", not an exception.
14
+ */
15
+ export declare function tryGetAdapter(ide: string): IdeAdapter | undefined;
4
16
  /** All registered adapter ids (insertion order). */
5
17
  export declare function listAdapterIds(): readonly IdeId[];
6
18
  /** All registered adapters (insertion order). */
@@ -39,6 +39,20 @@ export function getAdapter(ide) {
39
39
  }
40
40
  return adapter;
41
41
  }
42
+ /**
43
+ * Non-throwing twin of `getAdapter`. Returns `undefined` for an id with
44
+ * no registered adapter instead of raising.
45
+ *
46
+ * For callers that hold an IDE-shaped string which is NOT guaranteed to
47
+ * be an `IdeId`: `detectIdeFromEnv` returns `IdeKind` (`'claude-code' |
48
+ * 'trae' | 'opencode' | 'unknown'`), and `'opencode'` has no adapter
49
+ * registered. Casting to `IdeId` and calling `getAdapter` would throw
50
+ * inside a pure formatting/threshold path — the caller wants "no
51
+ * adapter → fall back", not an exception.
52
+ */
53
+ export function tryGetAdapter(ide) {
54
+ return ADAPTERS.get(ide);
55
+ }
42
56
  /** All registered adapter ids (insertion order). */
43
57
  export function listAdapterIds() {
44
58
  return Array.from(ADAPTERS.keys());
@@ -25,6 +25,20 @@ export interface IdeSettingsLocation {
25
25
  readonly dirName: string;
26
26
  /** settings 文件名(部分 IDE 叫 settings.json / mcp.json) */
27
27
  readonly settingsFileName: string;
28
+ /**
29
+ * 该 IDE 的**机器本地** settings 层文件名(Claude Code = `settings.local.json`),
30
+ * 与 `settingsFileName` 同目录、通常被 gitignore,IDE 读取时叠加在其上。
31
+ * `undefined` = 该 IDE 没有这一层;调用方此时回落到自己的默认值。
32
+ *
33
+ * peaks-loop 只在"写进去的东西是机器相关"时用这一层 —— 例如 per-OS 的
34
+ * hook shell shim —— 绝不写进共享的 `settingsFileName`。写 hook 的调用方
35
+ * (auto-compact 的 `ide-native` 通路)因此不需要知道任何 IDE 的目录/文件名,
36
+ * 只读适配器声明的这两个字段。
37
+ *
38
+ * 可选:既有适配器无需改动;声明了 `compactPathway: 'ide-native'` 的适配器
39
+ * 应当一并声明此字段,否则会落到调用方的 claude-code 默认路径。
40
+ */
41
+ readonly localSettingsFileName?: string;
28
42
  /** 解析出 settings.json 绝对路径 */
29
43
  resolveSettingsFile(scope: 'project' | 'global', projectRoot: string | undefined): string;
30
44
  /** 该 IDE 是否支持此 scope(用于清晰报错) */
@@ -178,6 +192,13 @@ export interface IdeCompactProfile {
178
192
  * Slash command or shell-call to invoke compact. The orchestrator
179
193
  * spawns this via `child_process.spawn` (shell-exec pathway) or
180
194
  * writes it to an IDE hook file (ide-native pathway).
195
+ *
196
+ * ZERO EXECUTOR as of slice 2026-09-13-auto-compact-trigger-ownership: every
197
+ * `shell-exec` branch now only REPORTS that it will not spawn (see
198
+ * `auto-compact-dispatcher.ts`), and the `ide-native` branch registers a
199
+ * harness-side trigger instead. The field is retained because the sibling
200
+ * work (A2, harness-side state re-injection) still needs it; nothing in this
201
+ * slice deletes it. Do not read it as a capability peaks-loop has.
181
202
  */
182
203
  readonly compactCommand: string;
183
204
  /**
@@ -195,6 +216,19 @@ export interface IdeCompactProfile {
195
216
  * adapters still on the v2.11.x model).
196
217
  */
197
218
  readonly compactPathway: 'shell-exec' | 'ide-native' | 'llm-self-compress' | 'noop';
219
+ /**
220
+ * Optional settings key / env var the IDE reads its AUTO-COMPACT WINDOW
221
+ * from — i.e. the window the harness itself compacts against. When
222
+ * declared, peaks-loop writes the window it computes its ratio against
223
+ * into the adapter's machine-local settings `env` block, under this key,
224
+ * so both sides reference one number instead of two independent
225
+ * resolutions (slice 2026-09-13-auto-compact-trigger-ownership).
226
+ *
227
+ * `undefined` = this IDE exposes no such knob. peaks-loop then cannot
228
+ * close the drift; `peaks compact harness-window` reports that instead of
229
+ * pretending. Adapters that do not opt in are unchanged.
230
+ */
231
+ readonly autoCompactWindowEnvVar?: string;
198
232
  /**
199
233
  * Optional command the runner invokes post-compact to confirm
200
234
  * ratio dropped (e.g. `peaks code auto-compact --json`). When omitted,
@@ -263,6 +297,31 @@ export interface ContextPercentFallbackInput {
263
297
  * Slice 2026-09-09-context-window-override.
264
298
  */
265
299
  readonly configWindowTokens?: unknown;
300
+ /**
301
+ * Raw value of the adapter's `autoCompactWindowEnvVar`, resolved by the
302
+ * generic reader from the adapter's own declarations (settings path +
303
+ * key name) — the window peaks-loop configured for the harness. Feeds the
304
+ * `harness-env` layer of `resolveContextWindow`.
305
+ *
306
+ * Slice 2026-09-13-auto-compact-trigger-ownership.
307
+ */
308
+ readonly harnessWindowTokens?: unknown;
309
+ /**
310
+ * True when `harnessWindowTokens` is a value peaks-loop ITSELF wrote (the
311
+ * harness settings carry the provenance marker matching it), false when a
312
+ * human set the key by hand.
313
+ *
314
+ * The `harness-env` layer outranks the model heuristic either way — that is
315
+ * what keeps peaks-loop's ratio and the harness's trigger on one number. The
316
+ * flag decides only whether the late 1M rescue may OVERRULE that layer when
317
+ * the observed context proves it too small: peaks-loop's own earlier
318
+ * resolution is self-correcting, whereas a human's explicit pin must not be
319
+ * silently rewritten (and, since it is persisted, permanently so).
320
+ *
321
+ * Omitted = treated as not peaks-written (the safe direction: do not touch
322
+ * what you did not write).
323
+ */
324
+ readonly harnessWindowPeakWritten?: boolean;
266
325
  }
267
326
  /**
268
327
  * Per-IDE standards-file location + format profile. Used by the
@@ -20,7 +20,7 @@ function packageNameFor(key) {
20
20
  }
21
21
  function probeNpx() {
22
22
  const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
23
- const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
23
+ const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv, windowsHide: true });
24
24
  return probe.status === 0;
25
25
  }
26
26
  /** Named code for "npm itself could not be launched" — distinct from a registry miss. */
@@ -35,7 +35,7 @@ function probePackage(key) {
35
35
  // own JS entry and this runs it through `process.execPath`. Same shape as the
36
36
  // npx probe above and as `eslint-runner.ts`.
37
37
  const { command, args, baseEnv } = resolveNpmInvocation(['view', `${pkg}@${pin}`, 'version']);
38
- const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
38
+ const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv, windowsHide: true });
39
39
  return {
40
40
  ok: result.status === 0,
41
41
  error: result.error === undefined || result.error === null ? null : result.error.message
@@ -91,7 +91,8 @@ function loadDiffRanges(cwd) {
91
91
  const result = spawnSync('git', ['diff', 'HEAD', '--unified=0', '--no-color'], {
92
92
  cwd,
93
93
  encoding: 'utf8',
94
- maxBuffer: DIFF_BUFFER_BYTES
94
+ maxBuffer: DIFF_BUFFER_BYTES,
95
+ windowsHide: true
95
96
  });
96
97
  if (result.status !== 0 || typeof result.stdout !== 'string')
97
98
  return [];
@@ -260,6 +261,7 @@ export function runEslint(options) {
260
261
  const spawnOptions = {
261
262
  cwd: projectRoot,
262
263
  encoding: 'utf8',
264
+ windowsHide: true,
263
265
  timeout: options.timeoutMs ?? ESLINT_DEFAULT_TIMEOUT_MS,
264
266
  maxBuffer: OUTPUT_BUFFER_BYTES
265
267
  };
@@ -60,7 +60,8 @@ function execPeaks(args, cwd, peaksBin) {
60
60
  const stdout = execFileSync(cmd[0], [...cmd.slice(1), ...args], {
61
61
  cwd,
62
62
  stdio: ['ignore', 'pipe', 'ignore'],
63
- encoding: 'utf8'
63
+ encoding: 'utf8',
64
+ windowsHide: true
64
65
  });
65
66
  return { stdout, exitCode: 0 };
66
67
  }
@@ -136,7 +136,7 @@ export function executeProjectMemoryBackup(options) {
136
136
  const safeMemoryDir = assertSafeProjectMemoryDir(plan.projectRoot);
137
137
  mkdirSync(plan.backupMemoryDir, { recursive: true });
138
138
  for (const copy of plan.plannedCopies) {
139
- const sourcePath = realPathOrThrow(copy.sourcePath, 'Project memory source must stay inside the project memory directory');
139
+ const sourcePath = realPathOrThrow(copy.sourcePath, 'Project memory source must stay inside the project memory directory', 'Project memory source does not exist');
140
140
  if (!isInsidePath(sourcePath, stableRealPath(safeMemoryDir))) {
141
141
  throw new Error('Project memory source must stay inside the project memory directory');
142
142
  }
@@ -1,6 +1,14 @@
1
1
  export declare function normalizeRoot(path: string): string;
2
2
  export declare function normalizeRealRoot(path: string): string;
3
- export declare function realPathOrThrow(path: string, errorMessage: string): string;
3
+ /**
4
+ * Resolve `path` to its realpath, refusing missing paths and symlinks.
5
+ *
6
+ * `missingPathMessage` is required because the two refusals mean different
7
+ * things to the user: "there is nothing at this path" is a caller typo, while
8
+ * "this path is a symlink" is an escape attempt. A single shared message made
9
+ * `peaks memory extract --artifact <typo>` report a sandbox-escape warning.
10
+ */
11
+ export declare function realPathOrThrow(path: string, errorMessage: string, missingPathMessage: string): string;
4
12
  export declare function resolveProjectPath(path: string, projectRoot: string): string;
5
13
  export declare function assertInsideProject(path: string, projectRoot: string): string;
6
14
  export declare function assertSafeProjectMemoryDir(projectRoot: string): string;
@@ -8,8 +8,9 @@
8
8
  // canonical form the rest of the codebase expects (handles input-path
9
9
  // quirks from the path-utils layer).
10
10
  // - `realPathOrThrow` — refuses symlinks and missing paths. Throws with
11
- // a stable message so callers can distinguish "missing" from "escape
12
- // attempt".
11
+ // a stable, path-free message, and takes ONE MESSAGE PER REASON, so
12
+ // callers can distinguish "missing" from "escape attempt" by the
13
+ // message they supplied.
13
14
  // - `assertInsideProject` — confirms an artifact path resolves inside
14
15
  // the project root after realpath, throwing the same kind of error
15
16
  // message realPathOrThrow uses.
@@ -32,9 +33,17 @@ export function normalizeRoot(path) {
32
33
  export function normalizeRealRoot(path) {
33
34
  return stableRealPath(path);
34
35
  }
35
- export function realPathOrThrow(path, errorMessage) {
36
+ /**
37
+ * Resolve `path` to its realpath, refusing missing paths and symlinks.
38
+ *
39
+ * `missingPathMessage` is required because the two refusals mean different
40
+ * things to the user: "there is nothing at this path" is a caller typo, while
41
+ * "this path is a symlink" is an escape attempt. A single shared message made
42
+ * `peaks memory extract --artifact <typo>` report a sandbox-escape warning.
43
+ */
44
+ export function realPathOrThrow(path, errorMessage, missingPathMessage) {
36
45
  if (!existsSync(path)) {
37
- throw new Error(errorMessage);
46
+ throw new Error(missingPathMessage);
38
47
  }
39
48
  const stats = lstatSync(path);
40
49
  if (stats.isSymbolicLink()) {
@@ -53,8 +62,8 @@ export function resolveProjectPath(path, projectRoot) {
53
62
  export function assertInsideProject(path, projectRoot) {
54
63
  const resolvedRoot = normalizeRoot(projectRoot);
55
64
  const resolvedPath = resolveProjectPath(path, resolvedRoot);
56
- const realProjectRoot = realPathOrThrow(resolvedRoot, 'Project root is not accessible');
57
- const realArtifactPath = realPathOrThrow(resolvedPath, 'Artifact path must stay inside the project root');
65
+ const realProjectRoot = realPathOrThrow(resolvedRoot, 'Project root is not accessible', 'Project root does not exist');
66
+ const realArtifactPath = realPathOrThrow(resolvedPath, 'Artifact path must stay inside the project root', 'Artifact path does not exist');
58
67
  if (!isInsidePath(realArtifactPath, realProjectRoot)) {
59
68
  throw new Error('Artifact path must stay inside the project root');
60
69
  }
@@ -105,6 +105,7 @@ export async function triggerBestPracticeScan(opts) {
105
105
  cwd: opts.projectRoot,
106
106
  stdio: 'ignore',
107
107
  detached: true,
108
+ windowsHide: true,
108
109
  env: { ...process.env, PEAKS_BEST_PRACTICE_STDIN: '' }
109
110
  });
110
111
  child.once('error', (err) => {
@@ -34,7 +34,8 @@ export interface PrecheckOptions {
34
34
  export interface PrecheckEnvelope {
35
35
  readonly ok: boolean;
36
36
  readonly overall: LayerStatus;
37
- readonly rootVersion: string;
37
+ /** `null` when the root `package.json` is missing or unreadable — see `rootVsShared`. */
38
+ readonly rootVersion: string | null;
38
39
  readonly strict: boolean;
39
40
  readonly snapshotAt: string;
40
41
  readonly layers: {