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.
- package/CHANGELOG.md +48 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-runtime-commands.d.ts +22 -0
- package/dist/cli/commands/code-runtime-commands.js +128 -34
- package/dist/cli/commands/compact-command.js +129 -1
- package/dist/cli/commands/container-commands.js +3 -3
- package/dist/cli/commands/core/skill-command.js +45 -10
- package/dist/cli/commands/cron-commands.js +2 -1
- package/dist/cli/commands/e2e-verify.js +3 -3
- package/dist/cli/commands/governance-classify-contract-commands.js +1 -0
- package/dist/cli/commands/hooks-commands.js +10 -1
- package/dist/cli/commands/loop-commands.js +1 -0
- package/dist/cli/commands/playwright-commands.js +2 -1
- package/dist/cli/commands/reinject-command.d.ts +72 -0
- package/dist/cli/commands/reinject-command.js +174 -0
- package/dist/cli/commands/request-commands.js +6 -3
- package/dist/cli/commands/shadcn-commands.js +1 -0
- package/dist/cli/commands/test-commands.js +2 -1
- package/dist/cli/commands/vm-commands.js +7 -7
- package/dist/cli/commands/workspace/init-command.js +24 -2
- package/dist/cli/commands/worktree-lease-commands.js +4 -4
- package/dist/cli/index.js +49 -2
- package/dist/cli/program.js +5 -0
- package/dist/hooks/pre-tool-use-sub-agent.js +1 -1
- package/dist/services/adapter/adapter-registry.js +1 -1
- package/dist/services/artifacts/artifact-service.js +1 -1
- package/dist/services/capability-guard-runner/contracts/J01.js +2 -1
- package/dist/services/capability-guard-runner/contracts/J02.js +3 -3
- package/dist/services/capability-guard-runner/contracts/J04.js +4 -2
- package/dist/services/capability-guard-runner/contracts/J07.js +2 -1
- package/dist/services/code/auto-compact-lifecycle.d.ts +50 -4
- package/dist/services/code/auto-compact-lifecycle.js +52 -10
- package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
- package/dist/services/code/auto-compact-orchestrator.js +173 -43
- package/dist/services/code/orchestrator-can-do.d.ts +4 -2
- package/dist/services/code/orchestrator-can-do.js +37 -5
- package/dist/services/codegraph/codegraph-exclude-reconciler.js +2 -1
- package/dist/services/codegraph/codegraph-process-runner.js +3 -2
- package/dist/services/compact/request-transition-hook.js +5 -2
- package/dist/services/compact-history/compact-history-service.d.ts +61 -0
- package/dist/services/compact-history/compact-history-service.js +49 -0
- package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +11 -2
- package/dist/services/compact-statusline/compact-lifecycle-store.js +19 -1
- package/dist/services/compact-statusline/compact-statusline-service.d.ts +1 -1
- package/dist/services/compact-statusline/compact-statusline-service.js +22 -0
- package/dist/services/config/config-safety.d.ts +52 -0
- package/dist/services/config/config-safety.js +75 -1
- package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
- package/dist/services/context/auto-compact-dispatcher.js +113 -40
- package/dist/services/context/auto-compact-reader.d.ts +68 -28
- package/dist/services/context/auto-compact-reader.js +155 -1
- package/dist/services/context/auto-compact-types.d.ts +89 -12
- package/dist/services/context/auto-compact-types.js +16 -32
- package/dist/services/context/harness-window-config.d.ts +412 -0
- package/dist/services/context/harness-window-config.js +607 -0
- package/dist/services/context/main-session-monitor.d.ts +27 -0
- package/dist/services/context/main-session-monitor.js +32 -1
- package/dist/services/context/post-compact-reinjection.d.ts +221 -0
- package/dist/services/context/post-compact-reinjection.js +491 -0
- package/dist/services/dispatch/merge-back-runner.js +5 -5
- package/dist/services/dispatch/service-shutdown.js +3 -3
- package/dist/services/doc/doc-generator.js +2 -1
- package/dist/services/env/shell-probe.js +1 -1
- package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
- package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
- package/dist/services/hooks/auto-compact-hook-install.js +8 -0
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
- package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
- package/dist/services/ide/ide-registry.d.ts +12 -0
- package/dist/services/ide/ide-registry.js +14 -0
- package/dist/services/ide/ide-types.d.ts +59 -0
- package/dist/services/lint/detect-eslint.js +2 -2
- package/dist/services/lint/eslint-runner.js +3 -1
- package/dist/services/loop/evaluator-dispatcher.js +2 -1
- package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
- package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
- package/dist/services/memory/project-memory-service/store/paths.js +15 -6
- package/dist/services/prd/best-practice-auto-trigger.js +1 -0
- package/dist/services/release/version-precheck-service.d.ts +2 -1
- package/dist/services/release/version-precheck-service.js +82 -12
- package/dist/services/runtime/vendor-adapter.d.ts +29 -4
- package/dist/services/runtime/vendors/claude-code.js +1 -1
- package/dist/services/runtime/vendors/codex.js +1 -1
- package/dist/services/runtime/vendors/copilot.js +1 -1
- package/dist/services/sc/sc-service.js +1 -1
- package/dist/services/scan/diff-scope-service.js +2 -2
- package/dist/services/scan/file-size-scan.js +2 -2
- package/dist/services/scan/orphan-service.js +2 -1
- package/dist/services/scan/type-sanity-service.js +2 -2
- package/dist/services/skillhub/tar-runtime.js +1 -0
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +8 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +40 -3
- package/dist/services/skills/hooks-settings-service.d.ts +12 -0
- package/dist/services/skills/hooks-settings-service.js +77 -10
- package/dist/services/skills/session-start-hook-constants.d.ts +41 -0
- package/dist/services/skills/session-start-hook-constants.js +41 -0
- package/dist/services/skills/skill-presence-service.js +9 -0
- package/dist/services/skills/skill-statusline-renderer.js +30 -5
- package/dist/services/skills/statusline-palette.d.ts +6 -0
- package/dist/services/skills/statusline-palette.js +4 -1
- package/dist/services/slice/slice-check-service.js +2 -1
- package/dist/services/slice/slice-decompose-runners.js +2 -1
- package/dist/services/upgrade/upgrade-service.js +1 -0
- package/dist/services/workflow/workflow-skip-service.js +2 -1
- package/dist/services/workspace/migrate-service.js +1 -1
- package/dist/services/workspace/workspace-claude-settings-materializer.js +51 -7
- package/dist/services/workspace/workspace-service.js +8 -0
- package/dist/services/worktree/host-worktree-reconciler.js +1 -0
- package/dist/services/worktree/long-path-cleanup.js +3 -2
- package/dist/shared/process.js +1 -1
- package/package.json +5 -5
- package/scripts/install-skills.mjs +1 -0
- package/scripts/watch.mjs +3 -1
- package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
- package/skills/bee/peaks-prd/SKILL.md +1 -1
- package/skills/bee/peaks-qa/SKILL.md +2 -2
- package/skills/bee/peaks-rd/SKILL.md +2 -2
- package/skills/bee/peaks-reviewer/SKILL.md +1 -1
- package/skills/bee/peaks-sc/SKILL.md +1 -1
- package/skills/bee/peaks-security-audit/SKILL.md +1 -1
- package/skills/bee/peaks-txt/SKILL.md +1 -1
- package/skills/bee/peaks-ui/SKILL.md +1 -1
- package/skills/peaks-audit/SKILL.md +1 -1
- package/skills/peaks-code/SKILL.md +4 -4
- package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
- package/skills/peaks-content/SKILL.md +1 -1
- package/skills/peaks-doctor/SKILL.md +1 -1
- package/skills/peaks-final-review/SKILL.md +1 -1
- package/skills/peaks-ide/SKILL.md +1 -1
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
- package/skills/peaks-resume/SKILL.md +1 -1
- package/skills/peaks-slice-decompose/SKILL.md +1 -1
- package/skills/peaks-solo/SKILL.md +1 -1
- package/skills/peaks-sop/SKILL.md +1 -1
- package/skills/peaks-status/SKILL.md +1 -1
- 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;
|