peaks-loop 4.0.45 → 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 (137) hide show
  1. package/CHANGELOG.md +48 -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 +128 -34
  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 +50 -4
  33. package/dist/services/code/auto-compact-lifecycle.js +52 -10
  34. package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
  35. package/dist/services/code/auto-compact-orchestrator.js +173 -43
  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/compact-statusline/compact-lifecycle-store.d.ts +11 -2
  44. package/dist/services/compact-statusline/compact-lifecycle-store.js +19 -1
  45. package/dist/services/compact-statusline/compact-statusline-service.d.ts +1 -1
  46. package/dist/services/compact-statusline/compact-statusline-service.js +22 -0
  47. package/dist/services/config/config-safety.d.ts +52 -0
  48. package/dist/services/config/config-safety.js +75 -1
  49. package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
  50. package/dist/services/context/auto-compact-dispatcher.js +113 -40
  51. package/dist/services/context/auto-compact-reader.d.ts +68 -28
  52. package/dist/services/context/auto-compact-reader.js +155 -1
  53. package/dist/services/context/auto-compact-types.d.ts +89 -12
  54. package/dist/services/context/auto-compact-types.js +16 -32
  55. package/dist/services/context/harness-window-config.d.ts +412 -0
  56. package/dist/services/context/harness-window-config.js +607 -0
  57. package/dist/services/context/main-session-monitor.d.ts +27 -0
  58. package/dist/services/context/main-session-monitor.js +32 -1
  59. package/dist/services/context/post-compact-reinjection.d.ts +221 -0
  60. package/dist/services/context/post-compact-reinjection.js +491 -0
  61. package/dist/services/dispatch/merge-back-runner.js +5 -5
  62. package/dist/services/dispatch/service-shutdown.js +3 -3
  63. package/dist/services/doc/doc-generator.js +2 -1
  64. package/dist/services/env/shell-probe.js +1 -1
  65. package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
  66. package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
  67. package/dist/services/hooks/auto-compact-hook-install.js +8 -0
  68. package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
  69. package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
  70. package/dist/services/ide/ide-registry.d.ts +12 -0
  71. package/dist/services/ide/ide-registry.js +14 -0
  72. package/dist/services/ide/ide-types.d.ts +59 -0
  73. package/dist/services/lint/detect-eslint.js +2 -2
  74. package/dist/services/lint/eslint-runner.js +3 -1
  75. package/dist/services/loop/evaluator-dispatcher.js +2 -1
  76. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
  77. package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
  78. package/dist/services/memory/project-memory-service/store/paths.js +15 -6
  79. package/dist/services/prd/best-practice-auto-trigger.js +1 -0
  80. package/dist/services/release/version-precheck-service.d.ts +2 -1
  81. package/dist/services/release/version-precheck-service.js +82 -12
  82. package/dist/services/runtime/vendor-adapter.d.ts +29 -4
  83. package/dist/services/runtime/vendors/claude-code.js +1 -1
  84. package/dist/services/runtime/vendors/codex.js +1 -1
  85. package/dist/services/runtime/vendors/copilot.js +1 -1
  86. package/dist/services/sc/sc-service.js +1 -1
  87. package/dist/services/scan/diff-scope-service.js +2 -2
  88. package/dist/services/scan/file-size-scan.js +2 -2
  89. package/dist/services/scan/orphan-service.js +2 -1
  90. package/dist/services/scan/type-sanity-service.js +2 -2
  91. package/dist/services/skillhub/tar-runtime.js +1 -0
  92. package/dist/services/skills/hooks-codegate-superpowers.d.ts +8 -0
  93. package/dist/services/skills/hooks-codegate-superpowers.js +40 -3
  94. package/dist/services/skills/hooks-settings-service.d.ts +12 -0
  95. package/dist/services/skills/hooks-settings-service.js +77 -10
  96. package/dist/services/skills/session-start-hook-constants.d.ts +41 -0
  97. package/dist/services/skills/session-start-hook-constants.js +41 -0
  98. package/dist/services/skills/skill-presence-service.js +9 -0
  99. package/dist/services/skills/skill-statusline-renderer.js +30 -5
  100. package/dist/services/skills/statusline-palette.d.ts +6 -0
  101. package/dist/services/skills/statusline-palette.js +4 -1
  102. package/dist/services/slice/slice-check-service.js +2 -1
  103. package/dist/services/slice/slice-decompose-runners.js +2 -1
  104. package/dist/services/upgrade/upgrade-service.js +1 -0
  105. package/dist/services/workflow/workflow-skip-service.js +2 -1
  106. package/dist/services/workspace/migrate-service.js +1 -1
  107. package/dist/services/workspace/workspace-claude-settings-materializer.js +51 -7
  108. package/dist/services/workspace/workspace-service.js +8 -0
  109. package/dist/services/worktree/host-worktree-reconciler.js +1 -0
  110. package/dist/services/worktree/long-path-cleanup.js +3 -2
  111. package/dist/shared/process.js +1 -1
  112. package/package.json +5 -5
  113. package/scripts/install-skills.mjs +1 -0
  114. package/scripts/watch.mjs +3 -1
  115. package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
  116. package/skills/bee/peaks-prd/SKILL.md +1 -1
  117. package/skills/bee/peaks-qa/SKILL.md +2 -2
  118. package/skills/bee/peaks-rd/SKILL.md +2 -2
  119. package/skills/bee/peaks-reviewer/SKILL.md +1 -1
  120. package/skills/bee/peaks-sc/SKILL.md +1 -1
  121. package/skills/bee/peaks-security-audit/SKILL.md +1 -1
  122. package/skills/bee/peaks-txt/SKILL.md +1 -1
  123. package/skills/bee/peaks-ui/SKILL.md +1 -1
  124. package/skills/peaks-audit/SKILL.md +1 -1
  125. package/skills/peaks-code/SKILL.md +4 -4
  126. package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
  127. package/skills/peaks-content/SKILL.md +1 -1
  128. package/skills/peaks-doctor/SKILL.md +1 -1
  129. package/skills/peaks-final-review/SKILL.md +1 -1
  130. package/skills/peaks-ide/SKILL.md +1 -1
  131. package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
  132. package/skills/peaks-resume/SKILL.md +1 -1
  133. package/skills/peaks-slice-decompose/SKILL.md +1 -1
  134. package/skills/peaks-solo/SKILL.md +1 -1
  135. package/skills/peaks-sop/SKILL.md +1 -1
  136. package/skills/peaks-status/SKILL.md +1 -1
  137. package/skills/peaks-test/SKILL.md +1 -1
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Harness auto-compact window ownership
3
+ * (slice 2026-09-13-auto-compact-trigger-ownership, rid
4
+ * 2026-09-13-auto-compact-trigger-ownership).
5
+ *
6
+ * The goal (user's words): *when* to trigger auto-compact must be
7
+ * peaks-loop's decision; *how* to compact stays the harness's capability.
8
+ *
9
+ * That only holds if the two sides agree on ONE number. Before this slice
10
+ * they each resolved a window independently: Claude Code used the window it
11
+ * knows for the model, while peaks-loop divided by `resolveContextWindow()`
12
+ * (env → config → model-name heuristic → 200_000 default). A 1M-window model
13
+ * whose id the heuristic does not recognise made peaks-loop's "95%" land at
14
+ * 190K while the harness waited for ~967K — a 5× early trigger, and (before
15
+ * T3) a deadlock, because nothing could lower the ratio.
16
+ *
17
+ * This module owns the OTHER half of the fix: the harness itself accepts a
18
+ * window override (`IdeCompactProfile.autoCompactWindowEnvVar`, declared per
19
+ * adapter — no IDE names here), so peaks-loop writes the very number it
20
+ * computes the ratio against into the harness's own machine-local settings
21
+ * file. Reader and writer then reference one artifact instead of two
22
+ * resolutions that must "remember" to agree.
23
+ *
24
+ * Vendor neutrality: this module knows about a JSON file with an `env` block
25
+ * and nothing else. The settings path and the env-var name are BOTH passed
26
+ * in by the caller, resolved from the IDE adapter's declarations. There is
27
+ * no IDE id, no `.claude` literal, and no registry import in this file.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import { isUserHomeProjectRoot } from '../config/config-safety.js';
32
+ /**
33
+ * Peaks-owned opt-out flag, read from the SAME machine-local `env` block the
34
+ * window key is written to.
35
+ *
36
+ * Why it exists: `resetHarnessWindow` must be a real rollback. Without a
37
+ * durable opt-out, the next context probe would simply write the key back
38
+ * and the user's "remove it" would last until the next tool call — a
39
+ * rollback in name only. Absent (or any value other than `off`) = sync is
40
+ * enabled, which keeps every project installed by an earlier release
41
+ * unchanged.
42
+ */
43
+ export const HARNESS_WINDOW_SYNC_OPTOUT_KEY = 'PEAKS_HARNESS_WINDOW_SYNC';
44
+ /** The one value that disables the sync. */
45
+ export const HARNESS_WINDOW_SYNC_OPTOUT_VALUE = 'off';
46
+ /**
47
+ * Provenance marker: the window value peaks-loop ITSELF last wrote, recorded
48
+ * in the same `env` block as the window key.
49
+ *
50
+ * Why it must exist. The window key is a SHARED artifact — the harness reads
51
+ * it, the user may set it by hand (it is a documented Claude Code variable),
52
+ * and peaks-loop writes it. Two rules collide on it:
53
+ *
54
+ * - the ratio peaks-loop reports must divide by the key (the single-source
55
+ * rule), so the key outranks the model heuristic; and
56
+ * - a first-time mis-resolution must not become PERMANENT.
57
+ *
58
+ * The second rule needs the late 1M rescue to be able to overrule the key when
59
+ * the observed context proves the key too small. Applied to a value a HUMAN
60
+ * pinned, that same rescue would silently destroy an explicit setting — the
61
+ * user asks for an early compact, a long session outgrows the pin, and
62
+ * peaks-loop rewrites the file to 1M, permanently. Applied to peaks-loop's own
63
+ * earlier output it is simply self-correction.
64
+ *
65
+ * This marker is what tells the two apart: it records what peaks-loop wrote,
66
+ * so a mismatch means the human has taken the key over and the rescue must
67
+ * stand down. It travels in the same write as the value it describes, so the
68
+ * two can never disagree about which write they belong to.
69
+ */
70
+ export const HARNESS_WINDOW_WRITTEN_KEY = 'PEAKS_HARNESS_WINDOW_WRITTEN';
71
+ /**
72
+ * The harness's OWN accepted band for its auto-compact window (E1, rid
73
+ * 2026-09-13-defects-e).
74
+ *
75
+ * THE BAND IS A PROPERTY OF THE KEY, NOT OF peaks-loop. `autoCompactWindow` —
76
+ * and the `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env var that shadows it — is
77
+ * documented as "how full the context window gets before Claude Code compacts
78
+ * automatically, in tokens from 100000 to 1000000", and a value outside that
79
+ * band is not the window the harness compacts on: below the minimum the value
80
+ * is ignored, above the maximum it is reduced to the model's own context size
81
+ * (`Math.min(native, override)`; no Claude model's window exceeds 1000000, so
82
+ * above the maximum the reduction is certain).
83
+ *
84
+ * Either way the outcome is the failure this whole module exists to prevent:
85
+ * peaks-loop divides its ratio by a number the harness is not using, so
86
+ * "85%" names a point the harness will never fire at. A positive integer is
87
+ * therefore NOT sufficient — the band is part of what the value means.
88
+ *
89
+ * Clamping instead was rejected. A clamped write would put a DIFFERENT number
90
+ * in the file from the one the probe just divided by, restoring the
91
+ * two-resolutions drift inside the single write that is supposed to delete it.
92
+ * Refusing keeps the disagreement in one place, reportable, and reported.
93
+ */
94
+ export const HARNESS_WINDOW_MIN_TOKENS = 100_000;
95
+ /** See `HARNESS_WINDOW_MIN_TOKENS`. */
96
+ export const HARNESS_WINDOW_MAX_TOKENS = 1_000_000;
97
+ /**
98
+ * True when `tokens` is a window the harness will actually compact on.
99
+ * `null` (absent / unparseable) is NOT in band — there is no window at all.
100
+ */
101
+ export function isHarnessWindowInRange(tokens) {
102
+ return tokens !== null && tokens >= HARNESS_WINDOW_MIN_TOKENS && tokens <= HARNESS_WINDOW_MAX_TOKENS;
103
+ }
104
+ /**
105
+ * The BAND SENTENCE, in one place: both the write refusal and the read
106
+ * suppression have to say the same thing about the same numbers, and two
107
+ * copies would eventually disagree about the band.
108
+ */
109
+ function harnessWindowBand() {
110
+ return `${HARNESS_WINDOW_MIN_TOKENS}..${HARNESS_WINDOW_MAX_TOKENS}`;
111
+ }
112
+ /**
113
+ * The ONE wording of "what the harness-window sync just did", for both
114
+ * commands that sync (`peaks code context-now`, `peaks code auto-compact`).
115
+ *
116
+ * Why it lives here rather than inline at each call site: peaks-loop rewrites
117
+ * a file in the user's own harness settings on every probe, and the user
118
+ * accepted that write on one condition — 要告知 (tell me). Two copies of the
119
+ * sentence would mean a future edit tells half the users. This is the only
120
+ * definition; the CLI renders whatever it returns.
121
+ *
122
+ * The `written` branch is the load-bearing one: it names the key, the value,
123
+ * the file, what the value was before, and the exact rollback command, because
124
+ * the harness itself reports an override only through `/autocompact` — if
125
+ * peaks-loop does not say it here, nobody says it.
126
+ *
127
+ * The `skipped` branch is the one that is easy to get wrong, and did: it used
128
+ * to end at the reason token (`Harness window not managed (not-peaks-owned).`),
129
+ * which is true and tells the reader nothing. A refusal can be the exact
130
+ * moment the two sides came apart — the ratio divided by one number while the
131
+ * file pins another — so it is composed from the reason, the two numbers when
132
+ * they disagree, and the raw value when the file's value is not a number at
133
+ * all. See `harnessWindowConflictClause` / `unreadableWindowValueClause`.
134
+ */
135
+ export function describeHarnessWindowSync(result) {
136
+ if (result === null) {
137
+ return 'The active IDE adapter declares no auto-compact window key; this ratio cannot be tied to the harness trigger.';
138
+ }
139
+ if (result.action === 'written') {
140
+ return `WROTE ${result.key}=${String(result.tokens)} to ${result.settingsPath} (was ${result.previousTokens === null ? 'unset' : String(result.previousTokens)}) so the harness compacts at the same point this ratio measures. Rollback: \`peaks compact harness-window --reset\`.`;
141
+ }
142
+ if (result.action === 'skipped') {
143
+ // A refused write is NOT automatically a non-event. The two clauses below
144
+ // are the whole point of refusing to stop at the reason token: each names a
145
+ // concrete NUMBER the user can act on, because "not managed" alone left the
146
+ // reader unable to tell whether the two sides still agreed.
147
+ return `${skippedHarnessWindowClause(result)}${harnessWindowConflictClause(result)}${unreadableWindowValueClause(result)}`;
148
+ }
149
+ // `unchanged` — the file already holds exactly the window this probe divided
150
+ // by, so there is no disagreement to report. What can still be hidden is
151
+ // OWNERSHIP, and it decides what happens when the session outgrows the value.
152
+ // A user upgrading from a release that wrote the key before the provenance
153
+ // marker existed lands here (that is this repository's own state): the value
154
+ // is in force, peaks-loop computes against it, and nothing anywhere said it
155
+ // had become frozen — so "already in force" alone is not a full notice.
156
+ if (!result.peakWritten) {
157
+ return `Harness window ${result.key}=${String(result.tokens)} already in force, but it is NOT peaks-loop's own write: peaks-loop divides this ratio by it and will never raise it, so a session that outgrows ${String(result.tokens)} saturates the ratio at 1.0 instead of widening the window. To hand the key back to peaks-loop: \`peaks compact harness-window --reset\` (removes it), then \`peaks compact harness-window --reenable\` (the next probe fills it).`;
158
+ }
159
+ return `Harness window ${result.key} already in force; peaks-loop computes this ratio against it.`;
160
+ }
161
+ /**
162
+ * The same disagreement as `harnessWindowConflictClause`, reduced to one line
163
+ * for the `warnings` channel — the machine-readable half of 要告知, so a
164
+ * consumer reading the JSON envelope (or a human reading stderr) sees it even
165
+ * if it never renders `nextActions`. Returns `null` when there is nothing to
166
+ * warn about, which is the ordinary case.
167
+ */
168
+ export function harnessWindowSyncWarning(result) {
169
+ if (result === null || result.action !== 'skipped')
170
+ return null;
171
+ // E1: the resolved window is not one the harness accepts, so this ratio's
172
+ // denominator is un-anchored no matter what the file holds. Reported FIRST —
173
+ // it is the more fundamental disagreement, and the two clauses below would
174
+ // read as "a different number" rather than "a number the harness cannot use".
175
+ if (result.reason === 'out-of-harness-range') {
176
+ return `harness window out of range: this probe divided by ${String(result.requestedTokens)} tokens, outside the ${harnessWindowBand()} band the harness accepts — the ratio does not describe the harness's own trigger`;
177
+ }
178
+ if (result.requestedTokens !== null &&
179
+ result.previousTokens !== null &&
180
+ result.requestedTokens !== result.previousTokens) {
181
+ return `harness window conflict: this probe divided by ${result.requestedTokens} tokens but ${result.settingsPath} pins ${result.previousTokens} — the ratio does not describe the harness's own trigger`;
182
+ }
183
+ if (result.previousTokens === null && result.previousRawValue !== undefined) {
184
+ return `harness window unreadable: ${result.settingsPath} carries ${JSON.stringify(result.previousRawValue)}, which is not a plain token count`;
185
+ }
186
+ return null;
187
+ }
188
+ /**
189
+ * WHY nothing was written, in words — one clause per `reason`.
190
+ *
191
+ * Every reason has its own sentence rather than falling through to
192
+ * `(${reason})`: the bare token was what the user actually saw for the two
193
+ * most common refusals, and it names no value, no file, and no next step.
194
+ * The trailing clause is unreachable for the reasons this module produces
195
+ * today; it exists so a future reason cannot silently degrade to a token.
196
+ */
197
+ function skippedHarnessWindowClause(result) {
198
+ switch (result.reason) {
199
+ case 'opted-out':
200
+ return 'Harness window not managed (opted-out) — re-enable with `peaks compact harness-window --reenable`.';
201
+ case 'unsafe-project-root':
202
+ return `Harness window not managed: ${result.settingsPath} is the user's own home settings, not a project's, so peaks-loop left it alone. Re-run from inside a project (or point at one) and the window lands there instead.`;
203
+ case 'no-window-resolved':
204
+ return 'Harness window not managed: this probe measured a percentage and carried no token window, so there was no number to write — peaks-loop does not invent a window the adapter did not resolve.';
205
+ case 'out-of-harness-range':
206
+ return `Harness window not managed: the window this probe resolved (${String(result.requestedTokens)}) is outside the ${harnessWindowBand()} band the harness accepts, so writing it would put a number in ${result.settingsPath} that the harness does not compact on.`;
207
+ case 'unreadable-settings':
208
+ return `Harness window not managed: ${result.settingsPath} is not a JSON object peaks-loop can safely edit, so it was left exactly as found.`;
209
+ case 'not-peaks-owned':
210
+ return `Harness window not managed: ${result.settingsPath} already carries a window that peaks-loop did not write, and peaks-loop never overwrites (or claims) a value it did not write.`;
211
+ default:
212
+ return `Harness window not managed (${String(result.reason)}).`;
213
+ }
214
+ }
215
+ /**
216
+ * THE DISAGREEMENT SENTENCE — the one the user cannot get anywhere else.
217
+ *
218
+ * peaks-loop divided this probe's ratio by `requestedTokens`, while the
219
+ * harness's own settings file pins `previousTokens`. Neither side is wrong on
220
+ * its own, which is exactly why it went unnoticed: the file wins for the ratio
221
+ * (T1's single-source rule), the write that would have re-unified them was
222
+ * refused (B1's provenance guard), and the result was a percentage computed
223
+ * against a window the harness is not going to fire on. The harness reports an
224
+ * override only through its own `/autocompact`, so if peaks-loop does not name
225
+ * the two numbers here, nobody does.
226
+ *
227
+ * Both numbers and the file are named, and all three ways out are given,
228
+ * because this notice is the whole remedy for a state peaks-loop deliberately
229
+ * refuses to fix by writing.
230
+ */
231
+ function harnessWindowConflictClause(result) {
232
+ const requested = result.requestedTokens;
233
+ const pinned = result.previousTokens;
234
+ if (result.action !== 'skipped' || requested === null || pinned === null || requested === pinned)
235
+ return '';
236
+ return ` CONFLICT: peaks-loop divided this ratio by ${requested} tokens, but ${result.settingsPath} pins ${pinned} — and the harness compacts on ITS own number, so this ratio does not describe when it fires. Nothing was written. Choose one: (1) make peaks-loop's number match the file (unset PEAKS_CONTEXT_WINDOW_TOKENS, or change \`context.windowTokens\`), (2) keep the file as your own setting and read this ratio as a share of ${requested}, or (3) \`peaks compact harness-window --reset\` to remove the key and hand it back to peaks-loop.`;
237
+ }
238
+ /**
239
+ * The OTHER way two numbers can fail to line up: the file's value is not a
240
+ * number at all (`500k`, a hand-typed typo).
241
+ *
242
+ * `previousTokens` is `null` here — indistinguishable, in the parsed result,
243
+ * from an absent key — so this clause is gated on the RAW value. The
244
+ * disagreement is stated but never "aligned": peaks-loop will not overwrite a
245
+ * value it did not write, and the value is unusable, so the only honest output
246
+ * is to name it and hand the user the two ways out. Silent here would be the
247
+ * worst option of all, because the caller has already fallen back to a
248
+ * different window and the disk keeps claiming `500k` on every probe after it.
249
+ */
250
+ function unreadableWindowValueClause(result) {
251
+ if (result.action !== 'skipped' || result.previousTokens !== null || result.previousRawValue === undefined)
252
+ return '';
253
+ const raw = JSON.stringify(result.previousRawValue);
254
+ return ` The value in ${result.settingsPath} is ${raw}, which is not a plain token count: peaks-loop cannot read a window from it, so it resolved this ratio against its own fallback while the file keeps saying ${raw}. peaks-loop cannot align the two without overwriting a value it did not write — set the value to a plain token count, or run \`peaks compact harness-window --reset\` to remove the key and let the next probe write peaks-loop's number.`;
255
+ }
256
+ /**
257
+ * Parse a candidate window value. Positive finite integers only — a number,
258
+ * or a numeric string (the env block is JSON, so the value on disk is always
259
+ * a string there). The harness's own documentation is explicit that the
260
+ * variable "accepts only the plain token count" and NOT a `850k` suffix, so
261
+ * anything non-integral is refused here rather than written and silently
262
+ * ignored downstream.
263
+ */
264
+ export function parseHarnessWindowTokens(raw) {
265
+ if (typeof raw !== 'number' && typeof raw !== 'string')
266
+ return null;
267
+ if (typeof raw === 'string' && raw.trim().length === 0)
268
+ return null;
269
+ const parsed = typeof raw === 'number' ? raw : Number(raw.trim());
270
+ return Number.isFinite(parsed) && Number.isInteger(parsed) && parsed > 0 ? parsed : null;
271
+ }
272
+ /**
273
+ * Read the settings file as a JSON object. Returns null when the file is
274
+ * absent, unparseable, or not an object — the caller treats that as "cannot
275
+ * manage" rather than throwing, because a corrupt local settings file must
276
+ * never break a context probe.
277
+ */
278
+ function readSettingsObject(settingsPath) {
279
+ if (!existsSync(settingsPath))
280
+ return null;
281
+ try {
282
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
283
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
284
+ return null;
285
+ return parsed;
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ }
291
+ /** True when the file is absent (editable) or a parseable JSON object. */
292
+ function isEditable(settingsPath) {
293
+ if (!existsSync(settingsPath))
294
+ return true;
295
+ return readSettingsObject(settingsPath) !== null;
296
+ }
297
+ /**
298
+ * True when `candidate` is the user's home directory itself.
299
+ *
300
+ * Delegates to the shared `isUserHomeProjectRoot` — this guard used to carry
301
+ * its own copy of the predicate, and `resolveWritableProjectRoot` /
302
+ * `assertWritableProjectRoot` now need the same answer on every write path. Two
303
+ * definitions of "the user's home" is one too many: they are compared against
304
+ * the same `homedir()` under the same case-folding rules or they disagree about
305
+ * some path eventually. Tolerating a probe of a *subdirectory* of `$HOME` is
306
+ * deliberate — `~/proj` is an ordinary project — so this is an exact match, not
307
+ * `isInsidePath`.
308
+ */
309
+ function isUserHome(candidate) {
310
+ return isUserHomeProjectRoot(candidate);
311
+ }
312
+ function envString(settings, key) {
313
+ const env = settings?.env;
314
+ if (typeof env !== 'object' || env === null || Array.isArray(env))
315
+ return undefined;
316
+ return env[key];
317
+ }
318
+ /**
319
+ * Read the window the harness is currently using for auto-compact.
320
+ *
321
+ * Order: the process env FIRST (that is the value the RUNNING session
322
+ * captured at start-up, so it is what the harness is actually compacting
323
+ * against right now), then the settings file (what the next session will
324
+ * read). When the two disagree, the file is what the sync updates — the
325
+ * process env cannot be changed from inside a running session.
326
+ */
327
+ export function readHarnessWindow(input) {
328
+ const settings = readSettingsObject(input.location.settingsPath);
329
+ const optedOut = envString(settings, HARNESS_WINDOW_SYNC_OPTOUT_KEY) === HARNESS_WINDOW_SYNC_OPTOUT_VALUE;
330
+ // Provenance is a property of the KEY — "is peaks-loop the current writer of
331
+ // this key?" — so it is decided by the FILE's value against the marker,
332
+ // never by whichever copy happens to be in force.
333
+ //
334
+ // That distinction is load-bearing. A running session freezes the process
335
+ // env at start-up, so after peaks-loop refreshes the file the env still
336
+ // carries the PREVIOUS value. Comparing the marker against the in-force copy
337
+ // would then report "a human set this" for peaks-loop's own superseded
338
+ // number, switch the late 1M rescue off, and re-pin the ratio at 1.0 on the
339
+ // very next probe — the self-lock, restored through the back door. Against
340
+ // the file value the answer is stable: still ours, so still correctable.
341
+ const marker = parseHarnessWindowTokens(envString(settings, HARNESS_WINDOW_WRITTEN_KEY));
342
+ const fileRaw = envString(settings, input.location.envVar);
343
+ const fileTokens = parseHarnessWindowTokens(fileRaw);
344
+ const peakWritten = fileTokens !== null && marker === fileTokens;
345
+ const fromEnv = input.env?.[input.location.envVar];
346
+ if (fromEnv !== undefined) {
347
+ return {
348
+ tokens: parseHarnessWindowTokens(fromEnv), raw: fromEnv, source: 'process-env',
349
+ optedOut, peakWritten, fileTokens, fileRaw
350
+ };
351
+ }
352
+ if (fileRaw !== undefined) {
353
+ return { tokens: fileTokens, raw: fileRaw, source: 'settings-file', optedOut, peakWritten, fileTokens, fileRaw };
354
+ }
355
+ return {
356
+ tokens: null, raw: undefined, source: null, optedOut, peakWritten: false,
357
+ fileTokens: null, fileRaw: undefined
358
+ };
359
+ }
360
+ /**
361
+ * Write `tokens` as the harness's auto-compact window. Idempotent: a re-run
362
+ * with the same value performs no write at all, so repeated calls (the sync
363
+ * runs on every context probe) cannot churn the file or duplicate the entry.
364
+ *
365
+ * Everything else in the file is preserved verbatim — the caller's own `env`
366
+ * entries, every `hooks` entry, and any unknown top-level key. This is the
367
+ * same read-modify-write discipline `auto-compact-hook-install.ts` already
368
+ * applies to this file; there are now two writers, and both must leave the
369
+ * other's rows alone.
370
+ */
371
+ export function syncHarnessWindow(input) {
372
+ const settingsPath = input.location.settingsPath;
373
+ const current = readHarnessWindow({ location: input.location, env: input.env });
374
+ const fileSettings = readSettingsObject(settingsPath);
375
+ const fileRaw = envString(fileSettings, input.location.envVar);
376
+ const fileTokens = parseHarnessWindowTokens(fileRaw);
377
+ // Every branch spreads this, so `requestedTokens` and `previousRawValue`
378
+ // travel with the outcome rather than being re-derived by each consumer.
379
+ const base = {
380
+ settingsPath,
381
+ key: input.location.envVar,
382
+ previousTokens: fileTokens,
383
+ previousRawValue: fileRaw,
384
+ requestedTokens: input.tokens,
385
+ peakWritten: current.peakWritten
386
+ };
387
+ if (current.optedOut) {
388
+ return { ...base, action: 'skipped', reason: 'opted-out', tokens: current.tokens };
389
+ }
390
+ // The H1 guard: never target the user's own home directory. See
391
+ // `HarnessWindowLocation.projectRoot` for the trigger (`--project .` from a
392
+ // fresh terminal, whose cwd is `$HOME`). Refused here rather than by
393
+ // narrowing the shared project-root resolver: `findProjectRoot` deliberately
394
+ // stops below `$HOME` and `resolveCanonicalProjectRoot` is called from every
395
+ // command in the CLI, so a change there would move behaviour for all of them
396
+ // to close one writer's hazard. The check is exact-home only — `~/my-project`
397
+ // is a perfectly good project and is still written.
398
+ if (input.location.projectRoot !== undefined && isUserHome(input.location.projectRoot)) {
399
+ return { ...base, action: 'skipped', reason: 'unsafe-project-root', tokens: current.tokens };
400
+ }
401
+ if (input.tokens === null) {
402
+ return { ...base, action: 'skipped', reason: 'no-window-resolved', tokens: current.tokens };
403
+ }
404
+ // THE E1 BAND CHECK — before the idempotence and ownership gates, so an
405
+ // out-of-band request is refused whatever the file happens to hold. Writing it
406
+ // would be worse than useless: the harness ignores or caps it, so the file
407
+ // would carry a number that LOOKS managed while the ratio divides by a number
408
+ // the harness is not using. See `HARNESS_WINDOW_MIN_TOKENS`.
409
+ if (!isHarnessWindowInRange(input.tokens)) {
410
+ return { ...base, action: 'skipped', reason: 'out-of-harness-range', tokens: current.tokens };
411
+ }
412
+ if (!isEditable(settingsPath)) {
413
+ return { ...base, action: 'skipped', reason: 'unreadable-settings', tokens: current.tokens };
414
+ }
415
+ // Idempotence is a property of the FILE, not of the process env. A running
416
+ // session keeps the value it captured at start-up, so comparing against the
417
+ // read (env first) would report "changed" on every probe of that session and
418
+ // rewrite a byte-identical file each time.
419
+ if (fileTokens === input.tokens) {
420
+ return { ...base, action: 'unchanged', tokens: input.tokens };
421
+ }
422
+ // THE B1 GUARD. peaks-loop may update a value it wrote itself, and it may
423
+ // FILL an empty slot — but it must never overwrite a value it did not write.
424
+ //
425
+ // Round 2 decided this gate by comparing the file against the resolved
426
+ // window, and the resolved window is read env-first. So provenance gated the
427
+ // 1M *bump* but never the *write*: a human hand-edited the key from the
428
+ // 200000 peaks-loop wrote to 150000, a stale process env still said 200000,
429
+ // and the next probe reverted the human's edit and re-armed the marker — the
430
+ // marker then authorising the very rescue that raised the human's key to
431
+ // 1000000. No human was even needed: another session's stale env downgraded a
432
+ // 1M file the same way. Comparing the file against its own marker is the only
433
+ // question that has an answer stable across a frozen env.
434
+ const peakOwned = fileRaw === undefined || current.peakWritten;
435
+ if (!peakOwned) {
436
+ return { ...base, action: 'skipped', reason: 'not-peaks-owned', tokens: current.tokens };
437
+ }
438
+ const settings = fileSettings ?? {};
439
+ const env = typeof settings.env === 'object' && settings.env !== null && !Array.isArray(settings.env)
440
+ ? settings.env
441
+ : {};
442
+ const next = {
443
+ ...settings,
444
+ // A JSON env block is string-valued. `String(tokens)` is the plain token
445
+ // count the harness documents — never a `500k`-style suffix. The provenance
446
+ // marker is written in the SAME object, so value and provenance can never
447
+ // be split across two writes (see `HARNESS_WINDOW_WRITTEN_KEY`).
448
+ env: {
449
+ ...env,
450
+ [input.location.envVar]: String(input.tokens),
451
+ [HARNESS_WINDOW_WRITTEN_KEY]: String(input.tokens)
452
+ }
453
+ };
454
+ const dir = dirname(settingsPath);
455
+ if (!existsSync(dir))
456
+ mkdirSync(dir, { recursive: true });
457
+ // 2-space JSON + trailing newline matches the other writer of this file
458
+ // (`.claude/settings.local.json`), so a diff after a sync stays minimal.
459
+ writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
460
+ // `peakWritten` describes the file AFTER this call, and this call is what
461
+ // armed the marker — the pre-write read necessarily said `false`, because a
462
+ // write only happens when the file did not already hold the wanted value
463
+ // under a matching marker.
464
+ return { ...base, action: 'written', tokens: input.tokens, peakWritten: true };
465
+ }
466
+ /**
467
+ * Rollback: remove the window key and record the opt-out so the next sync
468
+ * does not put it straight back. Both edits land in the same
469
+ * read-modify-write, so there is no window in which the key is gone but the
470
+ * opt-out is not yet on disk.
471
+ *
472
+ * Idempotent: a second call reports `action: 'absent'` and rewrites nothing
473
+ * unless the opt-out row is missing.
474
+ */
475
+ export function resetHarnessWindow(input) {
476
+ const settingsPath = input.location.settingsPath;
477
+ const settings = readSettingsObject(settingsPath);
478
+ const previousTokens = parseHarnessWindowTokens(envString(settings, input.location.envVar));
479
+ const markerPresent = envString(settings, HARNESS_WINDOW_WRITTEN_KEY) !== undefined;
480
+ if (settings === null || !isEditable(settingsPath)) {
481
+ // Nothing to remove and nothing we can safely edit.
482
+ return { settingsPath, action: 'absent', previousTokens };
483
+ }
484
+ // NOTHING OF PEAKS-LOOP'S TO REMOVE → WRITE NOTHING.
485
+ //
486
+ // The old form short-circuited only when the opt-out was ALREADY recorded
487
+ // (`... && priorOptOut`), so `--reset` on a file peaks-loop had never touched
488
+ // still wrote `PEAKS_HARNESS_WINDOW_SYNC: "off"` into it. When that file is
489
+ // the user's PERSONAL `$HOME/.claude/settings.local.json` — a fresh terminal
490
+ // plus `--project .`, i.e. the ordinary case — the rollback inserted a
491
+ // peaks-loop row into a file with no peaks content at all: litter in the
492
+ // user's own settings, written by the command they ran to REMOVE something.
493
+ //
494
+ // A no-op is the honest outcome. There is no window to remove and no
495
+ // provenance marker to disarm, so there is nothing to make durable; the
496
+ // caller reports `absent` and says that nothing was written. A key the user
497
+ // deletes by hand but whose marker survives still falls through below, so the
498
+ // stale marker is still cleaned up — that is why the test is `markerPresent`
499
+ // and not `!priorOptOut`.
500
+ if (previousTokens === null && !markerPresent) {
501
+ return { settingsPath, action: 'absent', previousTokens };
502
+ }
503
+ const env = typeof settings.env === 'object' && settings.env !== null && !Array.isArray(settings.env)
504
+ ? { ...settings.env }
505
+ : {};
506
+ delete env[input.location.envVar];
507
+ // The provenance marker goes with the value it describes — a marker left
508
+ // behind would claim ownership of whatever the user writes next.
509
+ delete env[HARNESS_WINDOW_WRITTEN_KEY];
510
+ env[HARNESS_WINDOW_SYNC_OPTOUT_KEY] = HARNESS_WINDOW_SYNC_OPTOUT_VALUE;
511
+ const next = { ...settings, env };
512
+ writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
513
+ return { settingsPath, action: 'removed', previousTokens };
514
+ }
515
+ /**
516
+ * Record the opt-out WITHOUT removing anything: "stop managing this key",
517
+ * expressible at any moment — including before peaks-loop has ever written it.
518
+ *
519
+ * WHY THIS IS A SEPARATE ENTRY POINT AND NOT A FALLBACK OF `resetHarnessWindow`
520
+ *
521
+ * "Remove what is there" and "never write it again" are two different
522
+ * intentions. `--reset` on a file holding no peaks row is deliberately a no-op
523
+ * (see the file-litter note in `resetHarnessWindow`), so a user whose project
524
+ * peaks-loop has never touched had NO command for the second intention at all:
525
+ * they had to wait for a probe to write the key and then remove it — the order
526
+ * backwards. Folding the opt-out into `--reset` would fix that by making one
527
+ * verb mean "delete" or "don't write", depending on whether the file happened
528
+ * to hold a row, which is a verb whose effect the user cannot predict from its
529
+ * name. Two verbs, two intentions, each predictable.
530
+ *
531
+ * WHAT IT DOES NOT DO
532
+ * - it does not touch the window key: a hand-set value stays exactly as it
533
+ * was (peaks-loop would not have overwritten it anyway — see the B1 guard);
534
+ * - it does not write a provenance marker, so it claims no ownership of a
535
+ * value it did not write.
536
+ * The next probe reports `skipped / opted-out` and writes nothing.
537
+ *
538
+ * THE H1 HOME GUARD APPLIES HERE TOO (E4, rid 2026-09-13-defects-e).
539
+ *
540
+ * This function used to exempt itself, on the argument that `--disable` is an
541
+ * explicit instruction and "the location's own file is the only place the
542
+ * opt-out can be recorded for it to mean anything". The first half is true and
543
+ * was never the problem; the second half does not hold at `$HOME`:
544
+ *
545
+ * - the location is NOT usually explicit. `--project` is optional, and a
546
+ * fresh terminal starts in `$HOME`, so the ordinary invocation resolves the
547
+ * root to the user's home directory without them naming it — the very
548
+ * trigger the H1 guard was written for;
549
+ * - the opt-out has NOTHING to mean there. `syncHarnessWindow` already
550
+ * refuses to write the window at that root (`unsafe-project-root`), so the
551
+ * only thing a recorded opt-out changes is which sentence the refusal uses.
552
+ * Nothing is made expressible; a visible refusal is traded for a quieter one;
553
+ * - what IS added is a durable peaks-loop row in `~/.claude/settings.local.json`
554
+ * — a file outside every repo. `--reset`'s exemption does not transfer to
555
+ * this verb: `--reset` REMOVES (and is a no-op when there is nothing of
556
+ * peaks-loop's to remove), while `--disable` only ever ADDS.
557
+ *
558
+ * So the guard fires here exactly as it does in `syncHarnessWindow`: the same
559
+ * exact-home comparison, before any `mkdir` and before any read-modify-write.
560
+ * `~/my-project` is unaffected, and a user who really does carry a stale
561
+ * peaks-loop window key in `$HOME` still has `--reset`, which is allowed there
562
+ * for the reason its own note gives.
563
+ *
564
+ * Idempotent: a second call reports `already-opted-out` and rewrites nothing.
565
+ */
566
+ export function disableHarnessWindowSync(input) {
567
+ const settingsPath = input.location.settingsPath;
568
+ // Checked FIRST, before `isEditable` and before any directory is created:
569
+ // a refusal that has already mkdir'd `$HOME/.claude` is not a refusal.
570
+ if (input.location.projectRoot !== undefined && isUserHome(input.location.projectRoot)) {
571
+ return { settingsPath, action: 'refused-unsafe-project-root' };
572
+ }
573
+ const settings = readSettingsObject(settingsPath);
574
+ if (!isEditable(settingsPath)) {
575
+ return { settingsPath, action: 'unreadable-settings' };
576
+ }
577
+ const env = typeof settings?.env === 'object' && settings.env !== null && !Array.isArray(settings.env)
578
+ ? { ...settings.env }
579
+ : {};
580
+ if (env[HARNESS_WINDOW_SYNC_OPTOUT_KEY] === HARNESS_WINDOW_SYNC_OPTOUT_VALUE) {
581
+ return { settingsPath, action: 'already-opted-out' };
582
+ }
583
+ env[HARNESS_WINDOW_SYNC_OPTOUT_KEY] = HARNESS_WINDOW_SYNC_OPTOUT_VALUE;
584
+ const dir = dirname(settingsPath);
585
+ if (!existsSync(dir))
586
+ mkdirSync(dir, { recursive: true });
587
+ writeFileSync(settingsPath, `${JSON.stringify({ ...(settings ?? {}), env }, null, 2)}\n`, 'utf8');
588
+ return { settingsPath, action: 'disabled' };
589
+ }
590
+ /**
591
+ * Undo the opt-out (the companion of `resetHarnessWindow`) so peaks-loop
592
+ * resumes owning the harness window.
593
+ */
594
+ export function reenableHarnessWindowSync(input) {
595
+ const settingsPath = input.location.settingsPath;
596
+ const settings = readSettingsObject(settingsPath);
597
+ if (settings === null)
598
+ return { settingsPath, action: 'absent' };
599
+ const env = typeof settings.env === 'object' && settings.env !== null && !Array.isArray(settings.env)
600
+ ? { ...settings.env }
601
+ : {};
602
+ if (env[HARNESS_WINDOW_SYNC_OPTOUT_KEY] === undefined)
603
+ return { settingsPath, action: 'absent' };
604
+ delete env[HARNESS_WINDOW_SYNC_OPTOUT_KEY];
605
+ writeFileSync(settingsPath, `${JSON.stringify({ ...settings, env }, null, 2)}\n`, 'utf8');
606
+ return { settingsPath, action: 'reenabled' };
607
+ }
@@ -71,6 +71,33 @@ export interface InFlightBatchProbe {
71
71
  readonly hasInFlightBatch: boolean;
72
72
  readonly sharedChannelEntries: number;
73
73
  }
74
+ /**
75
+ * Which compact pathway serves this IDE, read from the ADAPTER'S OWN
76
+ * declaration (`IdeCompactProfile.compactPathway`) rather than from the
77
+ * IDE's name.
78
+ *
79
+ * Slice 2026-09-12-auto-compact-vendor-neutrality: this decision used to
80
+ * be the inline `ide === 'claude-code' ? 'ide-native' : 'llm-self-compress'`
81
+ * — an IDE identity branch in a module that is otherwise pure, reading the
82
+ * IDE's NAME where the adapter registry holds the CAPABILITY. An IDE that
83
+ * registered `compactPathway: 'ide-native'` was still told to
84
+ * self-compress.
85
+ *
86
+ * Pure + synchronous: `tryGetAdapter` is a Map lookup with no IO, so this
87
+ * module stays IO-free (same seam discipline as the `env?` parameter).
88
+ * `tryGetAdapter` (not `getAdapter`) because the input is an `IdeKind`,
89
+ * which includes ids — `opencode` — with no registered adapter; throwing
90
+ * from a suggestion-formatting path would be a regression.
91
+ *
92
+ * Only `'ide-native'` is in-band. Every other declared pathway
93
+ * (`shell-exec`, `llm-self-compress`, `noop`) reports the
94
+ * always-available `'llm-self-compress'` fallback, because the trigger
95
+ * union can only carry those two values. That under-reports a `noop`
96
+ * adapter — which the dispatcher would refuse outright — and callers
97
+ * therefore MUST NOT read this as a promise that a compact will happen;
98
+ * the dispatcher's `ok` remains the authority.
99
+ */
100
+ export declare function compactPathwayForIde(ide: IdeKind): 'ide-native' | 'llm-self-compress';
74
101
  export declare function pickMainSessionTrigger(opts: {
75
102
  promptSize: number;
76
103
  ide?: IdeKind | undefined;