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
@@ -12,6 +12,55 @@
12
12
  // read.
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
+ /**
16
+ * Pair each dispatched compact with the measurement that followed it. Pure;
17
+ * tolerates a file written by an earlier release (no `windowTokens`, no
18
+ * `kind`) — those rows produce a pair with a null window rather than an
19
+ * error, because the history file is append-only and never rewritten.
20
+ */
21
+ export function computeWindowCalibration(events) {
22
+ const pairs = [];
23
+ for (const event of events) {
24
+ const kind = event.kind ?? 'dispatch';
25
+ if (kind === 'observed') {
26
+ // Attach to the most recent dispatch still waiting for a measurement.
27
+ for (let i = pairs.length - 1; i >= 0; i -= 1) {
28
+ const candidate = pairs[i];
29
+ if (candidate.observedRatio !== null || typeof event.afterRatio !== 'number')
30
+ continue;
31
+ const observedTokens = candidate.windowTokens === null ? null : event.afterRatio * candidate.windowTokens;
32
+ pairs[i] = {
33
+ ...candidate,
34
+ observedRatio: event.afterRatio,
35
+ observedTokens,
36
+ driftTokens: observedTokens === null || candidate.requestedTokens === null
37
+ ? null
38
+ : observedTokens - candidate.requestedTokens,
39
+ measured: true
40
+ };
41
+ break;
42
+ }
43
+ continue;
44
+ }
45
+ const windowTokens = typeof event.windowTokens === 'number' ? event.windowTokens : null;
46
+ pairs.push({
47
+ dispatchedAt: event.ts,
48
+ windowTokens,
49
+ windowSource: event.windowSource ?? null,
50
+ requestedRatio: event.beforeRatio,
51
+ requestedTokens: windowTokens === null ? null : event.beforeRatio * windowTokens,
52
+ observedRatio: null,
53
+ observedTokens: null,
54
+ driftTokens: null,
55
+ measured: false
56
+ });
57
+ }
58
+ return {
59
+ pairs,
60
+ unmeasured: pairs.filter((p) => !p.measured).length,
61
+ lastWindowTokens: pairs.length === 0 ? null : pairs[pairs.length - 1].windowTokens
62
+ };
63
+ }
15
64
  export function readCompactHistory(input) {
16
65
  const path = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'compact-history.jsonl');
17
66
  if (!existsSync(path)) {
@@ -1,4 +1,12 @@
1
- export type CompactLifecycleStage = 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed';
1
+ export type CompactLifecycleStage = 'queued' | 'preparing' | 'compacting'
2
+ /**
3
+ * Slice 2026-09-12-compact-band-policy: a compact trigger was
4
+ * REGISTERED but no compaction has started — e.g. claude-code's
5
+ * `ide-native` pathway only installs the PreToolUse hook, which
6
+ * compacts in-band at ratio ≥ 0.95. In the 0.80–0.95 band nothing
7
+ * is in flight, so claiming `compacting` was a false heartbeat.
8
+ */
9
+ | 'armed' | 'verifying' | 'completed' | 'failed';
2
10
  export interface CompactLifecycleRecord {
3
11
  readonly schemaVersion: 1;
4
12
  readonly runId: string;
@@ -49,7 +57,8 @@ export declare function writeCompactLifecycle(input: {
49
57
  * Return kinds:
50
58
  * - `missing` — no record has ever been written for this session
51
59
  * - `valid` — record parsed and validated; stage is terminal
52
- * (completed/failed) OR is active but still fresh
60
+ * (completed/failed), resting (`armed`) OR is active
61
+ * but still fresh
53
62
  * - `invalid` — file exists but is malformed / wrong schema / out
54
63
  * of range. `reason` is a single-line English message
55
64
  * (no CLI verbs, no stack traces). The caller MUST
@@ -19,11 +19,27 @@
19
19
  // compacting / verifying) is `stalled` when (nowMs - updatedAt) >
20
20
  // staleAfterMs. Terminal stages (`completed`, `failed`) NEVER go
21
21
  // stalled — the record is the historical answer, not a heartbeat.
22
+ //
23
+ // Slice 2026-09-12-compact-band-policy (defect B): `armed` is a third
24
+ // category — a RESTING stage. It records a fact ("we registered a
25
+ // compact trigger") that stays true until a measurement supersedes it;
26
+ // no heartbeat was ever promised, so waiting in it is NORMAL and it
27
+ // must never be reported as `stalled`. Only `compacting` means "a
28
+ // compaction should be in flight right now", which is the only state
29
+ // where a missing heartbeat is real evidence of a stall.
22
30
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
23
31
  import { dirname, join } from 'node:path';
24
32
  import { getSessionDir } from '../session/getSessionDir.js';
25
33
  const LIFECYCLE_FILENAME = 'compact-lifecycle.json';
26
34
  const ERROR_SUMMARY_MAX = 160;
35
+ /**
36
+ * Stages that promise a heartbeat: each one is only written while the
37
+ * dispatching process (or the IDE it armed) is expected to make
38
+ * progress, so a stale `updatedAt` is real evidence of a stall.
39
+ *
40
+ * `armed` is deliberately NOT here — see the header. It is a resting
41
+ * state, not a heartbeat.
42
+ */
27
43
  const ACTIVE_STAGES = [
28
44
  'queued',
29
45
  'preparing',
@@ -34,6 +50,7 @@ const ALL_STAGES = [
34
50
  'queued',
35
51
  'preparing',
36
52
  'compacting',
53
+ 'armed',
37
54
  'verifying',
38
55
  'completed',
39
56
  'failed',
@@ -169,7 +186,8 @@ export function writeCompactLifecycle(input) {
169
186
  * Return kinds:
170
187
  * - `missing` — no record has ever been written for this session
171
188
  * - `valid` — record parsed and validated; stage is terminal
172
- * (completed/failed) OR is active but still fresh
189
+ * (completed/failed), resting (`armed`) OR is active
190
+ * but still fresh
173
191
  * - `invalid` — file exists but is malformed / wrong schema / out
174
192
  * of range. `reason` is a single-line English message
175
193
  * (no CLI verbs, no stack traces). The caller MUST
@@ -1,5 +1,5 @@
1
1
  import { type CompactLifecycleStage } from './compact-lifecycle-store.js';
2
- export type CompactDisplayKind = 'none' | 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed' | 'stalled' | 'invalid';
2
+ export type CompactDisplayKind = 'none' | 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed' | 'stalled' | 'invalid' | 'armed';
3
3
  export interface CompactStatuslineState {
4
4
  readonly kind: CompactDisplayKind;
5
5
  readonly filledCells: 0 | 2 | 4 | 6 | 8;
@@ -28,6 +28,9 @@
28
28
  // none → 0 cells
29
29
  // invalid → 0 cells (no false reassurance)
30
30
  // stalled → keep the active stage's cell
31
+ // armed → NO bar at all (slice 2026-09-12-compact-band-policy):
32
+ // a registered-but-idle trigger has no progress to
33
+ // report, and a bar would imply one.
31
34
  //
32
35
  // Render contract: the rendered label is a fixed-width 8-cell bar
33
36
  // (`[████░░░░]` filled from the left). NO `?` characters. NO guessed
@@ -36,6 +39,7 @@
36
39
  // measurement" hint.
37
40
  import { existsSync, readFileSync, statSync } from 'node:fs';
38
41
  import { getSessionDir } from '../session/getSessionDir.js';
42
+ import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
39
43
  import { readCompactLifecycle, } from './compact-lifecycle-store.js';
40
44
  /**
41
45
  * Concrete first-version stale timeout. Adjustable after real timing
@@ -70,6 +74,8 @@ const CELL_BY_STAGE = new Map([
70
74
  ['verifying', STAGE_CELL_VERIFYING],
71
75
  ['completed', STAGE_CELL_COMPLETED],
72
76
  ['failed', STAGE_CELL_COMPACTING],
77
+ // `armed` is not in the cell table: it renders WITHOUT a bar (see
78
+ // renderCompactStatusline). Lookups therefore miss and fall back to 0.
73
79
  ]);
74
80
  const FILLED = '█';
75
81
  const EMPTY = '░';
@@ -245,6 +251,12 @@ export function renderCompactStatusline(state) {
245
251
  return `compact ${renderBar(2)}`;
246
252
  case 'compacting':
247
253
  return `compact ${renderBar(STAGE_CELL_COMPACTING)}`;
254
+ // Slice 2026-09-12-compact-band-policy: a registered trigger that has
255
+ // not fired is NOT progress. Rendering a bar would imply movement
256
+ // that is not happening, so `armed` deliberately renders no bar at
257
+ // all — the label states what is true and what would change it.
258
+ case 'armed':
259
+ return `compact armed${formatArmedRatio(state)} — fires in-band at ${arRedLinePct()}`;
248
260
  case 'verifying':
249
261
  return `compact ${renderBar(STAGE_CELL_VERIFYING)}`;
250
262
  case 'completed':
@@ -257,6 +269,16 @@ export function renderCompactStatusline(state) {
257
269
  return formatInvalid(state);
258
270
  }
259
271
  }
272
+ /** The red-line ratio the armed trigger waits for, as a display percentage. */
273
+ function arRedLinePct() {
274
+ return `${Math.round(AUTO_COMPACT_RED_LINE_RATIO * 100)}%`;
275
+ }
276
+ /** ` (88% now)` when the opening trigger ratio is known; empty otherwise. */
277
+ function formatArmedRatio(state) {
278
+ return typeof state.triggerRatio === 'number'
279
+ ? ` (${Math.round(state.triggerRatio * 100)}% now)`
280
+ : '';
281
+ }
260
282
  function formatCompleted(state) {
261
283
  const bar = renderBar(STAGE_CELL_COMPLETED);
262
284
  if (typeof state.afterRatio === 'number') {
@@ -51,6 +51,58 @@ export declare class InvalidProjectRootError extends Error {
51
51
  constructor(reason: 'empty' | 'nul-byte' | 'non-existent' | 'non-canonical', input: string);
52
52
  }
53
53
  export declare function resolveCanonicalProjectRootStrict(startPath: string): string;
54
+ /**
55
+ * True when `projectRoot` IS the user's home directory itself.
56
+ *
57
+ * Exact match, NOT `isInsidePath`: `~/my-project` is an ordinary project and
58
+ * must keep working — the guard exists for the one directory that is never a
59
+ * project.
60
+ *
61
+ * `projectRootsMatch` rather than `===` because the two strings reach this
62
+ * function from different sources: a `--project .` argument arrives in the
63
+ * caller's spelling while `homedir()` yields the OS form (`C:\Users\x` vs
64
+ * `C:/Users/x`, and differing case on win32/darwin).
65
+ *
66
+ * This is the ONE definition of "the user's home" in the codebase; the
67
+ * harness-window writer's own guard delegates here so the two cannot drift.
68
+ */
69
+ export declare function isUserHomeProjectRoot(projectRoot: string): boolean;
70
+ /**
71
+ * Thrown by `assertWritableProjectRoot` / `resolveWritableProjectRoot` when a
72
+ * write would land in the user's home directory. Distinct from
73
+ * `InvalidProjectRootError` (which is about a malformed/absent path): this path
74
+ * is perfectly valid — it is just not a project.
75
+ */
76
+ export declare class UnsafeProjectRootError extends Error {
77
+ readonly projectRoot: string;
78
+ constructor(projectRoot: string);
79
+ }
80
+ /**
81
+ * Refuse a project root that IS the user's home directory.
82
+ *
83
+ * The hazard this closes: a fresh terminal starts in `$HOME`, and
84
+ * `peaks workspace init --project .` there resolved to `$HOME` (via
85
+ * `resolveCanonicalProjectRoot`'s fail-open fallback, which returns the start
86
+ * path when it can find no project marker) and then materialized a whole
87
+ * project tree INTO the user's home — `.claude/settings.local.json` above all,
88
+ * which is a live config file shared with every other project on the machine.
89
+ *
90
+ * Deliberately NOT enforced inside `resolveCanonicalProjectRoot`. That helper
91
+ * is called from 85 sites across 32 files, most of them READS (`peaks status`,
92
+ * `peaks dashboard`, `peaks memory`, the observability commands). Refusing
93
+ * there would break every one of them the moment a user runs a read command
94
+ * from their home directory, and its fail-open contract is documented and
95
+ * depended on. The hazard is a WRITE to `$HOME/.claude/`, so the guard belongs
96
+ * on write paths — this function, plus `resolveWritableProjectRoot` so a
97
+ * writer only has to make one call.
98
+ */
99
+ export declare function assertWritableProjectRoot(projectRoot: string): void;
100
+ /**
101
+ * `resolveCanonicalProjectRoot` for a path that is about to be WRITTEN to:
102
+ * same git-root-then-heuristic promotion, plus the home-directory refusal.
103
+ * Read paths keep using the fail-open helper.
104
+ */
105
+ export declare function resolveWritableProjectRoot(startPath: string): string;
54
106
  export declare function getProjectConfigPath(projectRoot: string | null): string | null;
55
107
  export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
56
108
  export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
+ import { projectRootsMatch } from '../../shared/path-utils.js';
6
7
  export function getUserConfigPath() {
7
8
  return resolve(homedir(), '.peaks', 'config.json');
8
9
  }
@@ -202,7 +203,8 @@ function resolveProjectRootFromGit(startPath) {
202
203
  const stdout = execFileSync('git', ['rev-parse', '--show-toplevel'], {
203
204
  cwd: startPath,
204
205
  encoding: 'utf8',
205
- stdio: ['ignore', 'pipe', 'ignore']
206
+ stdio: ['ignore', 'pipe', 'ignore'],
207
+ windowsHide: true
206
208
  });
207
209
  const trimmed = stdout.trim();
208
210
  if (trimmed.length === 0)
@@ -227,6 +229,78 @@ function resolveProjectRootFromGit(startPath) {
227
229
  return rawRoot;
228
230
  }
229
231
  }
232
+ /**
233
+ * True when `projectRoot` IS the user's home directory itself.
234
+ *
235
+ * Exact match, NOT `isInsidePath`: `~/my-project` is an ordinary project and
236
+ * must keep working — the guard exists for the one directory that is never a
237
+ * project.
238
+ *
239
+ * `projectRootsMatch` rather than `===` because the two strings reach this
240
+ * function from different sources: a `--project .` argument arrives in the
241
+ * caller's spelling while `homedir()` yields the OS form (`C:\Users\x` vs
242
+ * `C:/Users/x`, and differing case on win32/darwin).
243
+ *
244
+ * This is the ONE definition of "the user's home" in the codebase; the
245
+ * harness-window writer's own guard delegates here so the two cannot drift.
246
+ */
247
+ export function isUserHomeProjectRoot(projectRoot) {
248
+ try {
249
+ return projectRootsMatch(projectRoot, homedir());
250
+ }
251
+ catch {
252
+ // A path that cannot be resolved is not a claim about the home directory.
253
+ return false;
254
+ }
255
+ }
256
+ /**
257
+ * Thrown by `assertWritableProjectRoot` / `resolveWritableProjectRoot` when a
258
+ * write would land in the user's home directory. Distinct from
259
+ * `InvalidProjectRootError` (which is about a malformed/absent path): this path
260
+ * is perfectly valid — it is just not a project.
261
+ */
262
+ export class UnsafeProjectRootError extends Error {
263
+ projectRoot;
264
+ constructor(projectRoot) {
265
+ super(`Refusing to write into the user's home directory: "${projectRoot}" is not a project root.`);
266
+ this.projectRoot = projectRoot;
267
+ this.name = 'UnsafeProjectRootError';
268
+ }
269
+ }
270
+ /**
271
+ * Refuse a project root that IS the user's home directory.
272
+ *
273
+ * The hazard this closes: a fresh terminal starts in `$HOME`, and
274
+ * `peaks workspace init --project .` there resolved to `$HOME` (via
275
+ * `resolveCanonicalProjectRoot`'s fail-open fallback, which returns the start
276
+ * path when it can find no project marker) and then materialized a whole
277
+ * project tree INTO the user's home — `.claude/settings.local.json` above all,
278
+ * which is a live config file shared with every other project on the machine.
279
+ *
280
+ * Deliberately NOT enforced inside `resolveCanonicalProjectRoot`. That helper
281
+ * is called from 85 sites across 32 files, most of them READS (`peaks status`,
282
+ * `peaks dashboard`, `peaks memory`, the observability commands). Refusing
283
+ * there would break every one of them the moment a user runs a read command
284
+ * from their home directory, and its fail-open contract is documented and
285
+ * depended on. The hazard is a WRITE to `$HOME/.claude/`, so the guard belongs
286
+ * on write paths — this function, plus `resolveWritableProjectRoot` so a
287
+ * writer only has to make one call.
288
+ */
289
+ export function assertWritableProjectRoot(projectRoot) {
290
+ if (isUserHomeProjectRoot(projectRoot)) {
291
+ throw new UnsafeProjectRootError(projectRoot);
292
+ }
293
+ }
294
+ /**
295
+ * `resolveCanonicalProjectRoot` for a path that is about to be WRITTEN to:
296
+ * same git-root-then-heuristic promotion, plus the home-directory refusal.
297
+ * Read paths keep using the fail-open helper.
298
+ */
299
+ export function resolveWritableProjectRoot(startPath) {
300
+ const root = resolveCanonicalProjectRoot(startPath);
301
+ assertWritableProjectRoot(root);
302
+ return root;
303
+ }
230
304
  export function getProjectConfigPath(projectRoot) {
231
305
  if (!projectRoot)
232
306
  return null;
@@ -1,37 +1,3 @@
1
- /**
2
- * AC-3 — IDE-aware compact dispatcher.
3
- *
4
- * Reads the active IDE's `IdeAdapter.compact` profile and dispatches
5
- * compact via the adapter-declared pathway. No hard-coded IDE names
6
- * — Claude Code is the MVP that fills `compact`; other adapters
7
- * register their own `compact` profile as L2-dogfood verifies
8
- * each IDE's actual compact surface.
9
- *
10
- * Pathway dispatch:
11
- *
12
- * - `ide-native` — peaks-loop writes the compact intent to
13
- * the IDE's hook file (per
14
- * `IdeSettingsLocation`). Used when the IDE
15
- * requires a registered hook rather than a
16
- * runtime command.
17
- * - `llm-self-compress` — peaks-loop records the intent and
18
- * returns `ok: true` with `pathway` echoed.
19
- * The LLM does its own context summarization
20
- * on the next turn. Always available; least
21
- * precise fallback.
22
- * - `noop` — adapter explicitly opted out. peaks-loop
23
- * returns `ok: false` with `message: 'noop'`.
24
- * Used by legacy / unverified adapters.
25
- *
26
- * rid-031 (2026-07-28): `shell-exec` pathway is DEPRECATED. The
27
- * `case 'shell-exec':` branch remains reachable only to keep the
28
- * 2 currently-passing tests in `tests/unit/context/auto-compact-main-target.test.ts:58`
29
- * + `tests/unit/services/context/auto-compact-dispatcher-ide-native.test.ts:102`
30
- * passing (they assert `pathway: 'shell-exec'`). Real callers must
31
- * use `ide-native` (main session) or `llm-self-compress`. No host
32
- * CLI spawn occurs; the case logs a deprecation warning and returns
33
- * the same envelope shape.
34
- */
35
1
  import type { CompactDispatchResult } from './auto-compact-types.js';
36
2
  export type CompactTarget = 'main' | 'sub-agent';
37
3
  export interface DispatchIdeCompactInput {
@@ -48,14 +14,18 @@ export interface DispatchIdeCompactInput {
48
14
  * shells that spawn their own `peaks code auto-compact` flow pass
49
15
  * `'sub-agent'` to preserve the legacy shell-spawn behaviour.
50
16
  *
51
- * Behaviour matrix (claude-code MVP):
17
+ * Behaviour matrix, keyed on the ADAPTER'S DECLARED pathway only
18
+ * — never on the adapter's name (slice
19
+ * 2026-09-12-auto-compact-vendor-neutrality):
52
20
  * - target='main' → llm-self-compress (write intent; main LLM
53
- * fires `/compact` on its next turn).
21
+ * fires its compact command on its next turn).
54
22
  * - target='sub-agent'→ shell-exec stub (DEPRECATED — no host
55
23
  * CLI spawn; returns envelope with
56
24
  * `pathway: 'shell-exec'` for legacy
57
25
  * contract only — see rid-031).
58
- * Non-claude-code IDEs + target='main' return noop + warning.
26
+ * An adapter with no registered `compact` profile returns noop for
27
+ * BOTH targets; an adapter whose profile serves the main session is
28
+ * dispatched regardless of which IDE it is.
59
29
  */
60
30
  readonly target?: CompactTarget | undefined;
61
31
  }
@@ -1,3 +1,48 @@
1
+ /**
2
+ * AC-3 — IDE-aware compact dispatcher.
3
+ *
4
+ * Reads the active IDE's `IdeAdapter.compact` profile and dispatches
5
+ * compact via the adapter-declared pathway. No hard-coded IDE names
6
+ * — Claude Code is the MVP that fills `compact`; other adapters
7
+ * register their own `compact` profile as L2-dogfood verifies
8
+ * each IDE's actual compact surface.
9
+ *
10
+ * Pathway dispatch:
11
+ *
12
+ * - `ide-native` — peaks-loop writes the compact intent to
13
+ * the IDE's hook file (per
14
+ * `IdeSettingsLocation`). Used when the IDE
15
+ * requires a registered hook rather than a
16
+ * runtime command.
17
+ * - `llm-self-compress` — peaks-loop records the intent and
18
+ * returns `ok: true` with `pathway` echoed.
19
+ * The LLM does its own context summarization
20
+ * on the next turn. Always available; least
21
+ * precise fallback.
22
+ * - `noop` — adapter explicitly opted out. peaks-loop
23
+ * returns `ok: false` with `message: 'noop'`.
24
+ * Used by legacy / unverified adapters.
25
+ *
26
+ * rid-031 (2026-07-28): `shell-exec` pathway is DEPRECATED. Real
27
+ * callers must use `ide-native` (main session) or
28
+ * `llm-self-compress`. No host CLI spawn occurs; the `case
29
+ * 'shell-exec':` branch logs a deprecation warning and returns the
30
+ * same envelope shape.
31
+ *
32
+ * Slice 2026-09-12-auto-compact-vendor-neutrality (defect #2 of the QA
33
+ * pass): the paragraph above used to justify keeping that branch with
34
+ * "the 2 currently-passing tests" it named by path and line (`:58` of
35
+ * one file, `:102` of another, asserting `pathway: 'shell-exec'`).
36
+ * That justification ROTTED and is false as of today: both files were
37
+ * deleted by `f17aa377 test(rebuild): delete 559 legacy unit tests and
38
+ * reset vitest config`, and `grep -rn "shell-exec" tests/` now returns
39
+ * nothing — no test in this repo asserts `pathway: 'shell-exec'`. The
40
+ * branch is retained for the rid-031 ENVELOPE CONTRACT itself
41
+ * (`pathway` is part of `CompactDispatchResult` and is echoed to
42
+ * `peaks code auto-compact` callers), not for a test. Deleting it is a
43
+ * contract change, deliberately out of this slice.
44
+ */
45
+ import { join } from 'node:path';
1
46
  import { detectIdeFromEnv } from './ide-detect.js';
2
47
  import { getAdapter } from '../ide/ide-registry.js';
3
48
  /**
@@ -21,21 +66,28 @@ export async function dispatchIdeCompact(input) {
21
66
  // the main-session context. The orchestrator passes `'sub-agent'`
22
67
  // explicitly when a sub-agent shell dispatches the call.
23
68
  const target = input.target ?? 'main';
24
- // Slice 2026-06-28: when targeting the MAIN session, refuse
25
- // up-front for adapters we cannot dispatch a main-session compact
26
- // against. We DO this before the `!adapter.compact` short-circuit
27
- // so the test message reflects the operational cause (target vs
28
- // adapter capability). Even non-claude-code adapters without a
29
- // registered `compact` profile should report "main-session target
30
- // unsupported" rather than the generic "no compact profile" line.
31
- if (target === 'main' && ideId !== 'claude-code') {
32
- return {
33
- ok: false,
34
- ide: ideId,
35
- pathway: 'noop',
36
- message: `main-session target unsupported on adapter '${ideId}'; only claude-code supports in-band main-session compact.`
37
- };
38
- }
69
+ // Slice 2026-09-12-auto-compact-vendor-neutrality — the up-front
70
+ // `target === 'main' && ideId !== 'claude-code'` refusal is GONE.
71
+ //
72
+ // It decided by adapter NAME what the adapter's own profile already
73
+ // declares, contradicting this file's contract ("No hard-coded IDE
74
+ // names", :5-8): any adapter that filled in `compact` was still
75
+ // rejected for not being called `claude-code`.
76
+ //
77
+ // It was also redundant. Every pathway below owns its own
78
+ // main-session semantics — `ide-native` installs the hook against the
79
+ // caller's own window; `shell-exec` is explicitly degraded to
80
+ // `llm-self-compress` for a main target; `llm-self-compress` and
81
+ // `noop` behave identically for both targets (so `noop` still returns
82
+ // `ok: false` on main); an UNKNOWN pathway falls through to the
83
+ // default noop. An adapter with no `compact` profile keeps its own
84
+ // explicit refusal immediately below. No remaining capability test
85
+ // would have been anything but a duplicate of one of those branches,
86
+ // so the check was deleted rather than re-keyed. The two candidate
87
+ // re-keyings were both rejected: `pathway !== 'ide-native'` would
88
+ // refuse `shell-exec` and `llm-self-compress` adapters that the
89
+ // switch already serves, and `pathway === 'noop'` IS the branch
90
+ // immediately below.
39
91
  // Adapters that don't declare `compact` (legacy / unverified) →
40
92
  // explicit noop so the caller can distinguish "IDE doesn't support
41
93
  // compact" from "IDE supports but dispatch failed".
@@ -53,12 +105,13 @@ export async function dispatchIdeCompact(input) {
53
105
  switch (pathway) {
54
106
  case 'shell-exec':
55
107
  // rid-031 (2026-07-28): `shell-exec` pathway is DEPRECATED.
56
- // No host CLI spawn occurs. The case marker is preserved so
57
- // 2 currently-passing tests in
58
- // tests/unit/context/auto-compact-main-target.test.ts:58
59
- // tests/unit/services/context/auto-compact-dispatcher-ide-native.test.ts:102
60
- // continue to assert `pathway: 'shell-exec'`. Real callers must
61
- // use `ide-native` (main session) or `llm-self-compress`.
108
+ // No host CLI spawn occurs. The case marker is preserved for the
109
+ // rid-031 envelope contract (callers read `pathway`), NOT for a
110
+ // test — see this file's header, defect #2 of the 2026-09-12 QA
111
+ // pass: the tests the previous comment named were deleted by
112
+ // f17aa377 and no test asserts `pathway: 'shell-exec'` now.
113
+ // Real callers must use `ide-native` (main session) or
114
+ // `llm-self-compress`.
62
115
  console.warn(`compact: shell-exec pathway is deprecated on adapter '${ideId}' (target='${target}'); no host CLI spawn — use ide-native instead.`);
63
116
  if (target === 'main') {
64
117
  return {
@@ -76,12 +129,14 @@ export async function dispatchIdeCompact(input) {
76
129
  };
77
130
  case 'ide-native':
78
131
  // Slice 2026-07-02-auto-compact-zero-pause: write the auto-compact
79
- // PreToolUse hook into `.claude/settings.local.json`. The hook
80
- // command (`peaks code auto-compact`) reads
81
- // `CLAUDE_CONTEXT_USAGE_PERCENT` on every subsequent Bash/Task
82
- // tool call from the runner and, at ratio ≥ 0.95, in-band spawns
83
- // `claude --compact` against the CURRENT runner (not a child
84
- // process — the bug documented in
132
+ // PreToolUse hook into the IDE's MACHINE-LOCAL settings layer,
133
+ // resolved from the adapter (`settings.dirName` +
134
+ // `settings.localSettingsFileName`) — never from a path literal
135
+ // here. The hook command (`peaks code auto-compact`) reads the
136
+ // adapter-declared `envVarForContextPercent` on every subsequent
137
+ // Bash/Task tool call from the runner and, at ratio ≥ 0.95,
138
+ // in-band spawns the adapter-declared `compactCommand` against the
139
+ // CURRENT runner (not a child process — the bug documented in
85
140
  // `.peaks/memory/2026-06-27-auto-compact-design.md:139-152`).
86
141
  //
87
142
  // `ide-native` is ONLY for the main-session runner. When the
@@ -93,10 +148,11 @@ export async function dispatchIdeCompact(input) {
93
148
  if (target === 'sub-agent') {
94
149
  // rid-031 (2026-07-28): legacy shell-spawn fallback is
95
150
  // DEPRECATED. No host CLI spawn occurs. The envelope is
96
- // returned with `pathway: 'shell-exec'` to preserve the
97
- // contract asserted by
98
- // tests/unit/services/context/auto-compact-dispatcher-ide-native.test.ts:102
99
- // (sub-agent shells historically relied on this path).
151
+ // returned with `pathway: 'shell-exec'` to preserve the rid-031
152
+ // contract for callers that read `pathway` (sub-agent shells
153
+ // historically relied on this path). The test the previous
154
+ // comment named was deleted by f17aa377 — see this file's
155
+ // header, defect #2 of the 2026-09-12 QA pass.
100
156
  console.warn(`compact: sub-agent shell-exec fallback is deprecated on adapter '${ideId}'; no host CLI spawn for '${profile.compactCommand}'.`);
101
157
  return {
102
158
  ok: true,
@@ -111,7 +167,9 @@ export async function dispatchIdeCompact(input) {
111
167
  return await dispatchIdeNativeHook({
112
168
  projectRoot: input.projectRoot,
113
169
  sessionId: input.sessionId,
114
- target
170
+ target,
171
+ adapter,
172
+ profile
115
173
  });
116
174
  case 'llm-self-compress':
117
175
  return {
@@ -141,11 +199,18 @@ export async function dispatchIdeCompact(input) {
141
199
  /**
142
200
  * Slice 2026-07-02-auto-compact-zero-pause: implement the
143
201
  * `ide-native` pathway. Writes the auto-compact PreToolUse hook
144
- * into `.claude/settings.local.json` (idempotent; the install
145
- * service is a no-op if the hook is already present). On the next
146
- * Bash/Task tool call from the runner, the hook fires
147
- * `peaks code auto-compact` which in-band spawns
148
- * `claude --compact` against the CURRENT runner session.
202
+ * into the IDE's MACHINE-LOCAL settings file (idempotent; the
203
+ * install service is a no-op if the hook is already present). On
204
+ * the next Bash/Task tool call from the runner, the hook fires
205
+ * `peaks code auto-compact` which in-band spawns the adapter's
206
+ * declared compact command against the CURRENT runner session.
207
+ *
208
+ * Slice 2026-09-12-auto-compact-vendor-neutrality: the settings path
209
+ * AND the echoed `ide` are read off the adapter, so this function no
210
+ * longer names a vendor. An adapter that declares
211
+ * `compactPathway: 'ide-native'` and its own `settings` location gets
212
+ * its own hook file — the `.claude/settings.local.json` value survives
213
+ * only as the installer's default for callers with no adapter in hand.
149
214
  *
150
215
  * Returns `ok: true, pathway: 'ide-native'` regardless of install
151
216
  * action (`installed` vs `already-installed`) — both states
@@ -158,13 +223,21 @@ async function dispatchIdeNativeHook(input) {
158
223
  // avoids a static cycle if future slices add cross-imports
159
224
  // between dispatcher and hook-install.
160
225
  const { installAutoCompactHook } = await import('../hooks/auto-compact-hook-install.js');
161
- const result = installAutoCompactHook({ projectRoot: input.projectRoot });
226
+ // Resolve the local settings file from the adapter's declared
227
+ // location. `undefined` means "this IDE declares no machine-local
228
+ // layer" → the installer's documented default applies.
229
+ const localFileName = input.adapter.settings.localSettingsFileName;
230
+ const settingsPath = localFileName === undefined
231
+ ? undefined
232
+ : join(input.projectRoot, input.adapter.settings.dirName, localFileName);
233
+ const result = installAutoCompactHook({ projectRoot: input.projectRoot, settingsPath });
234
+ const envVar = input.profile.envVarForContextPercent;
162
235
  return {
163
236
  ok: true,
164
- ide: 'claude-code',
237
+ ide: input.adapter.id,
165
238
  pathway: 'ide-native',
166
239
  message: result.action === 'installed'
167
- ? `Auto-compact PreToolUse hook installed at ${result.settingsPath}. Next Bash/Task tool call will read CLAUDE_CONTEXT_USAGE_PERCENT and compact in-band at ratio ≥ 95%.`
240
+ ? `Auto-compact PreToolUse hook installed at ${result.settingsPath}. Next Bash/Task tool call will read ${envVar} and compact in-band at ratio ≥ 95%.`
168
241
  : result.action === 'updated'
169
242
  ? `Auto-compact PreToolUse hook REPAIRED at ${result.settingsPath} — the installed entry carried a stale command and was rewritten.`
170
243
  : `Auto-compact PreToolUse hook already installed at ${result.settingsPath}; next Bash/Task tool call will trigger compact in-band at ratio ≥ 95%.`