peaks-loop 4.0.46 → 4.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-runtime-commands.d.ts +22 -0
  5. package/dist/cli/commands/code-runtime-commands.js +99 -16
  6. package/dist/cli/commands/compact-command.js +129 -1
  7. package/dist/cli/commands/container-commands.js +3 -3
  8. package/dist/cli/commands/core/skill-command.js +45 -10
  9. package/dist/cli/commands/cron-commands.js +2 -1
  10. package/dist/cli/commands/e2e-verify.js +3 -3
  11. package/dist/cli/commands/governance-classify-contract-commands.js +1 -0
  12. package/dist/cli/commands/hooks-commands.js +10 -1
  13. package/dist/cli/commands/loop-commands.js +1 -0
  14. package/dist/cli/commands/playwright-commands.js +2 -1
  15. package/dist/cli/commands/reinject-command.d.ts +72 -0
  16. package/dist/cli/commands/reinject-command.js +174 -0
  17. package/dist/cli/commands/request-commands.js +6 -3
  18. package/dist/cli/commands/shadcn-commands.js +1 -0
  19. package/dist/cli/commands/test-commands.js +2 -1
  20. package/dist/cli/commands/vm-commands.js +7 -7
  21. package/dist/cli/commands/workspace/init-command.js +24 -2
  22. package/dist/cli/commands/worktree-lease-commands.js +4 -4
  23. package/dist/cli/index.js +49 -2
  24. package/dist/cli/program.js +5 -0
  25. package/dist/hooks/pre-tool-use-sub-agent.js +1 -1
  26. package/dist/services/adapter/adapter-registry.js +1 -1
  27. package/dist/services/artifacts/artifact-service.js +1 -1
  28. package/dist/services/capability-guard-runner/contracts/J01.js +2 -1
  29. package/dist/services/capability-guard-runner/contracts/J02.js +3 -3
  30. package/dist/services/capability-guard-runner/contracts/J04.js +4 -2
  31. package/dist/services/capability-guard-runner/contracts/J07.js +2 -1
  32. package/dist/services/code/auto-compact-lifecycle.d.ts +11 -1
  33. package/dist/services/code/auto-compact-lifecycle.js +11 -4
  34. package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
  35. package/dist/services/code/auto-compact-orchestrator.js +153 -32
  36. package/dist/services/code/orchestrator-can-do.d.ts +4 -2
  37. package/dist/services/code/orchestrator-can-do.js +37 -5
  38. package/dist/services/codegraph/codegraph-exclude-reconciler.js +2 -1
  39. package/dist/services/codegraph/codegraph-process-runner.js +3 -2
  40. package/dist/services/compact/request-transition-hook.js +5 -2
  41. package/dist/services/compact-history/compact-history-service.d.ts +61 -0
  42. package/dist/services/compact-history/compact-history-service.js +49 -0
  43. package/dist/services/config/config-safety.d.ts +52 -0
  44. package/dist/services/config/config-safety.js +75 -1
  45. package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
  46. package/dist/services/context/auto-compact-dispatcher.js +113 -40
  47. package/dist/services/context/auto-compact-reader.d.ts +68 -28
  48. package/dist/services/context/auto-compact-reader.js +155 -1
  49. package/dist/services/context/auto-compact-types.d.ts +89 -12
  50. package/dist/services/context/auto-compact-types.js +16 -32
  51. package/dist/services/context/harness-window-config.d.ts +412 -0
  52. package/dist/services/context/harness-window-config.js +607 -0
  53. package/dist/services/context/main-session-monitor.d.ts +27 -0
  54. package/dist/services/context/main-session-monitor.js +32 -1
  55. package/dist/services/context/post-compact-reinjection.d.ts +221 -0
  56. package/dist/services/context/post-compact-reinjection.js +491 -0
  57. package/dist/services/dispatch/merge-back-runner.js +5 -5
  58. package/dist/services/dispatch/service-shutdown.js +3 -3
  59. package/dist/services/doc/doc-generator.js +2 -1
  60. package/dist/services/env/shell-probe.js +1 -1
  61. package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
  62. package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
  63. package/dist/services/hooks/auto-compact-hook-install.js +8 -0
  64. package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
  65. package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
  66. package/dist/services/ide/ide-registry.d.ts +12 -0
  67. package/dist/services/ide/ide-registry.js +14 -0
  68. package/dist/services/ide/ide-types.d.ts +59 -0
  69. package/dist/services/lint/detect-eslint.js +2 -2
  70. package/dist/services/lint/eslint-runner.js +3 -1
  71. package/dist/services/loop/evaluator-dispatcher.js +2 -1
  72. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
  73. package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
  74. package/dist/services/memory/project-memory-service/store/paths.js +15 -6
  75. package/dist/services/prd/best-practice-auto-trigger.js +1 -0
  76. package/dist/services/release/version-precheck-service.d.ts +2 -1
  77. package/dist/services/release/version-precheck-service.js +82 -12
  78. package/dist/services/runtime/vendor-adapter.d.ts +29 -4
  79. package/dist/services/runtime/vendors/claude-code.js +1 -1
  80. package/dist/services/runtime/vendors/codex.js +1 -1
  81. package/dist/services/runtime/vendors/copilot.js +1 -1
  82. package/dist/services/sc/sc-service.js +1 -1
  83. package/dist/services/scan/diff-scope-service.js +2 -2
  84. package/dist/services/scan/file-size-scan.js +2 -2
  85. package/dist/services/scan/orphan-service.js +2 -1
  86. package/dist/services/scan/type-sanity-service.js +2 -2
  87. package/dist/services/skillhub/tar-runtime.js +1 -0
  88. package/dist/services/skills/hooks-codegate-superpowers.d.ts +8 -0
  89. package/dist/services/skills/hooks-codegate-superpowers.js +40 -3
  90. package/dist/services/skills/hooks-settings-service.d.ts +12 -0
  91. package/dist/services/skills/hooks-settings-service.js +77 -10
  92. package/dist/services/skills/session-start-hook-constants.d.ts +41 -0
  93. package/dist/services/skills/session-start-hook-constants.js +41 -0
  94. package/dist/services/skills/skill-presence-service.js +9 -0
  95. package/dist/services/slice/slice-check-service.js +2 -1
  96. package/dist/services/slice/slice-decompose-runners.js +2 -1
  97. package/dist/services/upgrade/upgrade-service.js +1 -0
  98. package/dist/services/workflow/workflow-skip-service.js +2 -1
  99. package/dist/services/workspace/migrate-service.js +1 -1
  100. package/dist/services/workspace/workspace-claude-settings-materializer.js +51 -7
  101. package/dist/services/workspace/workspace-service.js +8 -0
  102. package/dist/services/worktree/host-worktree-reconciler.js +1 -0
  103. package/dist/services/worktree/long-path-cleanup.js +3 -2
  104. package/dist/shared/process.js +1 -1
  105. package/package.json +5 -5
  106. package/scripts/install-skills.mjs +1 -0
  107. package/scripts/watch.mjs +3 -1
  108. package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
  109. package/skills/bee/peaks-prd/SKILL.md +1 -1
  110. package/skills/bee/peaks-qa/SKILL.md +2 -2
  111. package/skills/bee/peaks-rd/SKILL.md +2 -2
  112. package/skills/bee/peaks-reviewer/SKILL.md +1 -1
  113. package/skills/bee/peaks-sc/SKILL.md +1 -1
  114. package/skills/bee/peaks-security-audit/SKILL.md +1 -1
  115. package/skills/bee/peaks-txt/SKILL.md +1 -1
  116. package/skills/bee/peaks-ui/SKILL.md +1 -1
  117. package/skills/peaks-audit/SKILL.md +1 -1
  118. package/skills/peaks-code/SKILL.md +2 -2
  119. package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
  120. package/skills/peaks-content/SKILL.md +1 -1
  121. package/skills/peaks-doctor/SKILL.md +1 -1
  122. package/skills/peaks-final-review/SKILL.md +1 -1
  123. package/skills/peaks-ide/SKILL.md +1 -1
  124. package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
  125. package/skills/peaks-resume/SKILL.md +1 -1
  126. package/skills/peaks-slice-decompose/SKILL.md +1 -1
  127. package/skills/peaks-solo/SKILL.md +1 -1
  128. package/skills/peaks-sop/SKILL.md +1 -1
  129. package/skills/peaks-status/SKILL.md +1 -1
  130. package/skills/peaks-test/SKILL.md +1 -1
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Post-compact engineering-state re-injection
3
+ * (rid `2026-09-13-a2-post-compact-reinject`, session
4
+ * `2026-09-12-session-e37ef0`).
5
+ *
6
+ * THE PROBLEM THIS SOLVES
7
+ *
8
+ * `94bacb4a` settled WHO decides when to compact: peaks-loop owns the window,
9
+ * the harness performs the compaction. This module owns what happens AFTER
10
+ * it. Compaction summarizes a conversation, and a summarizer has no notion of
11
+ * a slice, a request, a gate or an acceptance criterion — so the engineering
12
+ * state is exactly the part it is most likely to drop. The user's word for
13
+ * the symptom is 飘逸 (drift): the session keeps talking but has lost its
14
+ * place.
15
+ *
16
+ * This repository already has the case on file:
17
+ * `.peaks/memory/per-turn-obligations-belong-in-per-turn-output.md` —
18
+ * text that lives in the message history (a SKILL.md body, a dispatch prompt)
19
+ * does not survive a compaction, so a rule that only exists there stops
20
+ * applying. The fix has to be re-injection, not a better summary.
21
+ *
22
+ * WHY A BUDGET IS THE DESIGN AND NOT A DETAIL
23
+ *
24
+ * The harness's own supported mechanism is a `SessionStart` hook whose stdout
25
+ * it adds to the context. So the card competes for the very space the
26
+ * compaction just freed. Re-injecting a lot is WORSE than re-injecting
27
+ * nothing: it re-fills the window, the ratio climbs back, and the next
28
+ * compaction arrives sooner — the cost is paid on every cycle. Hence a hard,
29
+ * declared ceiling (`POST_COMPACT_REINJECTION_BYTE_BUDGET`) and a
30
+ * deterministic rule for what is dropped (see `renderReinjectionCard`).
31
+ *
32
+ * POINT, DO NOT INLINE
33
+ *
34
+ * Two different things can be put in the card:
35
+ * - a FACT whose absence changes behaviour on the very next turn (the
36
+ * runtime rules, the next slice, the identity of the session), and
37
+ * - a BODY of material the model can go and read (a request artifact, a
38
+ * job state file, a memory file).
39
+ * Inlining the second is the worse trade at every size: it costs bytes on
40
+ * every compaction cycle forever, it goes stale the moment it is written, and
41
+ * the model cannot tell a stale copy from a live one. A path costs ~60 bytes,
42
+ * is always current, and the model is going to read the file anyway before it
43
+ * edits anything. So the card inlines only the first kind and spends one
44
+ * whole block on pointers to the second.
45
+ *
46
+ * VENDOR NEUTRALITY
47
+ *
48
+ * Nothing here knows an IDE, a settings file or a hook event. The card is a
49
+ * string; the hook entry that carries it lives with the other peaks hook
50
+ * entries (`session-start-hook-constants.ts`) and the settings plumbing is
51
+ * `applyHookInstall`. The only input that touches the outside world is
52
+ * `projectRoot`.
53
+ *
54
+ * FAIL-SOFT IS A HARD REQUIREMENT, AND IT HAS A SHARP EDGE HERE
55
+ *
56
+ * The hook's stdout IS context. So an error message printed on stdout is not
57
+ * a visible failure — it is a failure injected straight into the model's
58
+ * context as if peaks-loop had meant it. `buildPostCompactReinjectionCard`
59
+ * therefore never throws (every source is individually guarded and reports
60
+ * into `unresolved`), and the CLI wrapper that renders it prints NOTHING and
61
+ * exits 0 when the card cannot be built.
62
+ */
63
+ import { readdirSync, statSync } from 'node:fs';
64
+ import { join } from 'node:path';
65
+ import { readJobShapeDecision } from '../code/job-shape-decision.js';
66
+ import { tryReadJobProgress } from '../job/job-progress-store.js';
67
+ import { getSessionIdCanonical } from '../session/session-manager.js';
68
+ /**
69
+ * Hard ceiling on the card's size, in UTF-8 bytes.
70
+ *
71
+ * WHY 3072. Three numbers decided it, not taste:
72
+ *
73
+ * 1. It must be small against the freed space. A compaction releases tens
74
+ * of thousands of tokens. 3072 bytes is under ~1200 tokens of English —
75
+ * under 0.6% of a 200K window and under 0.12% of a 1M one. At that size
76
+ * the card cannot plausibly re-fill what the compaction freed, whatever
77
+ * the window, which is the property that makes the re-injection safe to
78
+ * run on every cycle.
79
+ * 2. It must be big enough for the whole content contract. Measured against
80
+ * this repository the full card — identity, pointers, rules, next action
81
+ * and progress — lands around 1.2-1.7 KiB. 3072 leaves roughly 2x
82
+ * headroom for long session ids, a long job id and a long absolute
83
+ * project root before the drop rule has to fire at all.
84
+ * 3. It must be a BYTE count rather than a token or percentage count. Bytes
85
+ * are computable on the hook path with `Buffer.byteLength` and no
86
+ * tokenizer, no window resolution and no dependency; and unlike a
87
+ * share-of-window budget, a fixed byte cap cannot silently GROW on a
88
+ * large-window model — which would reintroduce the very "re-fill the
89
+ * freed space" failure the cap exists to prevent. A percentage budget
90
+ * was considered and rejected for that reason.
91
+ */
92
+ export const POST_COMPACT_REINJECTION_BYTE_BUDGET = 3072;
93
+ /**
94
+ * Runtime rules that live ONLY in the message history, so a compaction
95
+ * destroys them.
96
+ *
97
+ * Why a repo-owned constant and not something parsed out of the dispatch
98
+ * record: the dispatch record is free-form prose written per slice, and a
99
+ * parser over it would silently stop matching the day someone rephrases a
100
+ * heading — a rule that vanishes without a failure. These six are peaks-loop's
101
+ * OWN operating rules for an agent working in a peaks-loop session; keeping
102
+ * them here makes adding one a one-line change with a test behind it.
103
+ *
104
+ * The membership test for an entry is: "if the model forgets this, does it
105
+ * act WRONGLY on the next turn?" Session-general advice that already lives in
106
+ * CLAUDE.md / the system prompt is deliberately absent — the system prompt is
107
+ * not part of the summarized history, so re-injecting it would spend budget
108
+ * to say something the model can already see.
109
+ */
110
+ export const AGENT_RUNTIME_RULES = Object.freeze([
111
+ 'CLI: run `node --import tsx src/cli/index.ts <cmd>`, never `pnpm exec tsx` — on Windows the latter truncates any argument at its first newline.',
112
+ 'Test scope: one file or pattern per run; the full suite requires PEAKS_FULL_TEST=1.',
113
+ 'Exit codes: never read a test result through a pipe (`| tail` reports tail\'s status); read ${PIPESTATUS[0]}.',
114
+ 'Never write `.claude/settings.local.json` from a slice — reproduce against a temp project copy via `--project <tmpdir>`.',
115
+ 'Git: commit / push policy is per-dispatch and is NOT inherited from this card — re-read the dispatch record before any state-mutating git command.',
116
+ 'No interactive questions: decide, act, and record the decision in the artifact.'
117
+ ]);
118
+ /**
119
+ * The block ranks, in survival order. The drop rule reads THIS list, so it is
120
+ * the single place the priority is stated.
121
+ *
122
+ * The ordering principle is value-per-byte, highest first:
123
+ * 0 IDENTITY — which session; ~1 line, and every other line depends on it
124
+ * 1 WHERE TO READ — the pointers; cheap, always current, and the only way the
125
+ * model reaches the bulky material it must not have inlined
126
+ * 2 RULES — behaviour-changing, and unavailable anywhere else in context
127
+ * 3 NEXT — one line; without it the next turn has no target
128
+ * 4 CURRENT WORK — request / job / change identity and progress
129
+ * RULES outranks CURRENT WORK deliberately: the identity of the slice is
130
+ * recoverable by Reading the artifact the card points at, whereas a rule the
131
+ * model has forgotten will be broken before it thinks to go looking.
132
+ */
133
+ export const REINJECTION_BLOCK_RANKS = Object.freeze([0, 1, 2, 3, 4]);
134
+ export const REINJECTION_BLOCK_HEADINGS = Object.freeze({
135
+ 0: '[peaks-loop] post-compact state card',
136
+ 1: 'WHERE TO READ',
137
+ 2: 'RULES THAT ARE NOT IN CONTEXT ANY MORE',
138
+ 3: 'NEXT',
139
+ 4: 'CURRENT WORK'
140
+ });
141
+ /** Short names for the omitted-marker. See `ReinjectionBlock.label`. */
142
+ export const REINJECTION_BLOCK_LABELS = Object.freeze({
143
+ 0: 'header',
144
+ 1: 'pointers',
145
+ 2: 'rules',
146
+ 3: 'next',
147
+ 4: 'current-work'
148
+ });
149
+ function byteLength(value) {
150
+ return Buffer.byteLength(value, 'utf8');
151
+ }
152
+ /**
153
+ * Render one block: a heading line, then the body lines indented two spaces.
154
+ * A block is the smallest unit the drop rule can remove — it is never split,
155
+ * because half a pointer list or half a rule reads as a complete answer and
156
+ * is worse than an absent one.
157
+ */
158
+ function renderBlock(block) {
159
+ const body = block.lines.map((line) => ` ${line}`).join('\n');
160
+ return body.length > 0 ? `${block.heading}\n${body}` : block.heading;
161
+ }
162
+ /**
163
+ * Assemble the card under a byte budget.
164
+ *
165
+ * THE RULE, stated so it can be checked rather than interpreted:
166
+ *
167
+ * 1. Blocks are rendered whole. Ranks are read in ascending order.
168
+ * 2. A block is emitted iff its bytes still fit in the remaining budget.
169
+ * Otherwise it is SKIPPED and the walk CONTINUES — greedy by rank, not
170
+ * stop-at-first-miss. This is the only one of the two candidate rules
171
+ * that keeps the survival order meaningful: with stop-at-first-miss, one
172
+ * oversized low-priority block would evict every block after it,
173
+ * including the hard rules. Skipping cannot reorder anything, because
174
+ * ranks are still walked in order.
175
+ * 3. Blocks are joined by a blank line, which is counted as part of the
176
+ * block being added (a separator is never emitted for a block that did
177
+ * not fit).
178
+ * 4. If anything was dropped and the remaining budget admits it, one final
179
+ * line names the dropped ranks. It is ALL-OR-NOTHING: it is emitted only
180
+ * if it fits entirely, so no string is ever truncated mid-way and the
181
+ * byte accounting stays exact.
182
+ *
183
+ * Deterministic in the strict sense: the output is a pure function of
184
+ * (blocks, budgetBytes). No clock, no filesystem, no "best effort".
185
+ *
186
+ * `budgetBytes` is injectable so the rule above is testable at any size; it
187
+ * defaults to the declared ceiling and no production caller overrides it.
188
+ */
189
+ export function renderReinjectionCard(input) {
190
+ const budget = input.budgetBytes ?? POST_COMPACT_REINJECTION_BYTE_BUDGET;
191
+ const ordered = [...input.blocks].sort((a, b) => a.rank - b.rank);
192
+ const parts = [];
193
+ const emittedRanks = [];
194
+ const droppedRanks = [];
195
+ let used = 0;
196
+ for (const block of ordered) {
197
+ const rendered = renderBlock(block);
198
+ // A separator only ever counts once a block already exists, and it is
199
+ // charged to the block being added so a skipped block leaves no residue.
200
+ const cost = byteLength(rendered) + (parts.length > 0 ? 2 : 0);
201
+ if (used + cost > budget) {
202
+ droppedRanks.push(block.rank);
203
+ continue;
204
+ }
205
+ parts.push(rendered);
206
+ emittedRanks.push(block.rank);
207
+ used += cost;
208
+ }
209
+ if (droppedRanks.length > 0) {
210
+ const labelsByRank = new Map(ordered.map((b) => [b.rank, b.label]));
211
+ const names = droppedRanks.map((rank) => labelsByRank.get(rank) ?? `rank ${rank}`).join(', ');
212
+ const marker = `[peaks-loop] omitted (budget ${budget}B): ${names}`;
213
+ const cost = byteLength(marker) + (parts.length > 0 ? 2 : 0);
214
+ if (used + cost <= budget) {
215
+ parts.push(marker);
216
+ used += cost;
217
+ }
218
+ }
219
+ const text = parts.join('\n\n');
220
+ return {
221
+ text,
222
+ bytes: byteLength(text),
223
+ budgetBytes: budget,
224
+ emittedRanks,
225
+ droppedRanks
226
+ };
227
+ }
228
+ /** Forward-slash a path so the card's pointers are shell-dialect neutral. */
229
+ function toPosix(p) {
230
+ return p.replaceAll('\\', '/');
231
+ }
232
+ /**
233
+ * Find the most recently modified request artifact under the session tree.
234
+ *
235
+ * This is a POINTER RESOLVER, not a content reader: it returns the artifact's
236
+ * name so the card can name it, and never opens it. Deliberately limited to
237
+ * `<session>/<role>/requests/*.md` — the tree every role already writes to —
238
+ * rather than a repo-wide search, so the answer cannot be some unrelated
239
+ * markdown file that happens to be newer.
240
+ *
241
+ * Returns its failures alongside the answer instead of swallowing them. The
242
+ * tempting shape here — a bare `catch { continue }` — makes two very different
243
+ * states indistinguishable to the caller: "this role has no requests yet"
244
+ * (ordinary, most roles) and "this role's requests directory exists but could
245
+ * not be read" (a real problem worth reporting). Only `ENOENT` is the ordinary
246
+ * one, so only `ENOENT` is silent.
247
+ */
248
+ function findLatestRequestArtifact(projectRoot, sessionId) {
249
+ const sessionDir = join(projectRoot, '.peaks', '_runtime', sessionId);
250
+ const warnings = [];
251
+ let roles;
252
+ try {
253
+ roles = readdirSync(sessionDir, { withFileTypes: true })
254
+ .filter((entry) => entry.isDirectory())
255
+ .map((entry) => entry.name)
256
+ .sort();
257
+ }
258
+ catch (err) {
259
+ // No session tree at all is the ordinary "nothing to point at" case; any
260
+ // other failure is reported.
261
+ if (errnoCode(err) !== 'ENOENT')
262
+ warnings.push(`session tree unreadable: ${sessionId}`);
263
+ return { rel: null, warnings };
264
+ }
265
+ let best = null;
266
+ for (const role of roles) {
267
+ const requestsDir = join(sessionDir, role, 'requests');
268
+ let files;
269
+ try {
270
+ files = readdirSync(requestsDir).filter((name) => name.endsWith('.md'));
271
+ }
272
+ catch (err) {
273
+ if (errnoCode(err) !== 'ENOENT')
274
+ warnings.push(`requests dir unreadable: ${role}/requests`);
275
+ continue;
276
+ }
277
+ for (const name of files) {
278
+ try {
279
+ const mtimeMs = statSync(join(requestsDir, name)).mtimeMs;
280
+ if (best === null || mtimeMs > best.mtimeMs) {
281
+ best = { rel: `${role}/requests/${name}`, mtimeMs };
282
+ }
283
+ }
284
+ catch {
285
+ // A single unreadable artifact must not hide the rest of the tree, but
286
+ // it must not disappear either — a rename/delete racing this read is
287
+ // the likely cause and it is worth knowing the answer is partial.
288
+ warnings.push(`request artifact unreadable: ${role}/requests/${name}`);
289
+ }
290
+ }
291
+ }
292
+ return { rel: best === null ? null : best.rel, warnings };
293
+ }
294
+ /** The POSIX errno code on a thrown value, or `undefined` for anything else. */
295
+ function errnoCode(err) {
296
+ const code = err?.code;
297
+ return typeof code === 'string' ? code : undefined;
298
+ }
299
+ /**
300
+ * Read every available fact. Never throws: each source is guarded separately
301
+ * and a failure is recorded in `unresolved` rather than propagated, because
302
+ * the caller is a session-start hook.
303
+ */
304
+ export function resolvePostCompactReinjectionFacts(input) {
305
+ const projectRoot = input.projectRoot;
306
+ const unresolved = [];
307
+ let sessionId = input.sessionId ?? null;
308
+ if (sessionId === null) {
309
+ try {
310
+ sessionId = getSessionIdCanonical(projectRoot);
311
+ }
312
+ catch {
313
+ sessionId = null;
314
+ }
315
+ if (sessionId === null)
316
+ unresolved.push('session-id (no canonical binding)');
317
+ }
318
+ let jobId = null;
319
+ let isJob = false;
320
+ if (sessionId !== null) {
321
+ try {
322
+ const record = readJobShapeDecision(projectRoot, sessionId);
323
+ isJob = record.decision.isJob;
324
+ jobId = record.decision.suggestedJobId;
325
+ }
326
+ catch {
327
+ unresolved.push('job-shape (not decided for this session)');
328
+ }
329
+ }
330
+ let progress = null;
331
+ if (sessionId !== null && jobId !== null) {
332
+ try {
333
+ const read = tryReadJobProgress(projectRoot, sessionId, jobId);
334
+ if (read === null) {
335
+ unresolved.push('job-progress (no progress.json yet)');
336
+ }
337
+ else {
338
+ progress = { done: read.done, total: read.total, currentSlice: read.currentSlice };
339
+ }
340
+ }
341
+ catch {
342
+ unresolved.push('job-progress (unreadable)');
343
+ }
344
+ }
345
+ let latestRequest = null;
346
+ if (sessionId !== null) {
347
+ try {
348
+ const found = findLatestRequestArtifact(projectRoot, sessionId);
349
+ latestRequest = found.rel;
350
+ for (const warning of found.warnings)
351
+ unresolved.push(`latest-request (${warning})`);
352
+ }
353
+ catch {
354
+ // The resolver reports its own failures; this is only for the
355
+ // unforeseen, and it is still reported rather than silently dropped.
356
+ unresolved.push('latest-request (resolver threw)');
357
+ }
358
+ if (latestRequest === null)
359
+ unresolved.push('latest-request (none on disk)');
360
+ }
361
+ return { projectRoot, sessionId, jobId, isJob, progress, latestRequest, unresolved };
362
+ }
363
+ function buildBlocks(facts) {
364
+ const root = toPosix(facts.projectRoot);
365
+ const blocks = [];
366
+ // Rank 0 — identity.
367
+ const identity = [`project: ${root}`];
368
+ identity.push(`session: ${facts.sessionId ?? 'unbound'}`);
369
+ blocks.push({
370
+ rank: 0,
371
+ heading: REINJECTION_BLOCK_HEADINGS[0] ?? 'state',
372
+ label: REINJECTION_BLOCK_LABELS[0] ?? 'rank-0',
373
+ lines: identity
374
+ });
375
+ // Rank 1 — pointers. Relative paths: the model's cwd is the project root,
376
+ // so a relative path is both shorter and stable across machines.
377
+ const pointers = [];
378
+ if (facts.sessionId !== null) {
379
+ const base = `.peaks/_runtime/${facts.sessionId}`;
380
+ pointers.push(`session tree: ${base}/`);
381
+ pointers.push(`requests: read the newest file under ${base}/<role>/requests/`);
382
+ if (facts.jobId !== null)
383
+ pointers.push(`job progress: ${base}/job/${facts.jobId}/progress.json`);
384
+ pointers.push(`job shape: ${base}/job-shape.json`);
385
+ pointers.push(`dispatch records: .peaks/_sub_agents/${facts.sessionId}/dispatch-*.json`);
386
+ }
387
+ else {
388
+ pointers.push('session tree: unbound — run `peaks workspace init --project .` first');
389
+ }
390
+ pointers.push('project memory: .peaks/memory/ (read the index before assuming)');
391
+ blocks.push({
392
+ rank: 1,
393
+ heading: REINJECTION_BLOCK_HEADINGS[1] ?? 'where to read',
394
+ label: REINJECTION_BLOCK_LABELS[1] ?? 'rank-1',
395
+ lines: pointers
396
+ });
397
+ // Rank 2 — the rules that only exist in the summarized history.
398
+ blocks.push({
399
+ rank: 2,
400
+ heading: REINJECTION_BLOCK_HEADINGS[2] ?? 'rules',
401
+ label: REINJECTION_BLOCK_LABELS[2] ?? 'rank-2',
402
+ lines: [...AGENT_RUNTIME_RULES]
403
+ });
404
+ // Rank 3 — next action.
405
+ const next = [];
406
+ if (facts.progress !== null) {
407
+ next.push(`job ${facts.jobId ?? '?'}: slice ${facts.progress.done + 1}/${facts.progress.total} (${facts.progress.currentSlice})`);
408
+ }
409
+ else if (facts.isJob) {
410
+ next.push(`job ${facts.jobId ?? '?'}: no progress.json yet — resume at the first unfinished slice`);
411
+ }
412
+ else if (facts.latestRequest !== null) {
413
+ // NOT "continue <path>". The newest artifact is the best available guess
414
+ // at the live request, but it is only a guess — a sibling role writing to
415
+ // its own `requests/` directory bumps that directory's mtimes too, so the
416
+ // card names the candidate and sends the model to the authority (the
417
+ // dispatch record) rather than promoting a heuristic into an instruction.
418
+ next.push(`no active job — confirm the live request from the dispatch record, then continue it`);
419
+ }
420
+ else {
421
+ next.push('no job and no request on disk — re-read the user request before acting');
422
+ }
423
+ blocks.push({
424
+ rank: 3,
425
+ heading: REINJECTION_BLOCK_HEADINGS[3] ?? 'next',
426
+ label: REINJECTION_BLOCK_LABELS[3] ?? 'rank-3',
427
+ lines: next
428
+ });
429
+ // Rank 4 — current work identity + progress.
430
+ const current = [];
431
+ current.push(`session: ${facts.sessionId ?? 'unbound'}`);
432
+ if (facts.jobId !== null) {
433
+ current.push(`job: ${facts.jobId}${facts.isJob ? ' (job mode)' : ''}`);
434
+ }
435
+ if (facts.progress !== null) {
436
+ current.push(`progress: ${facts.progress.done}/${facts.progress.total} — current ${facts.progress.currentSlice}`);
437
+ }
438
+ if (facts.latestRequest !== null) {
439
+ current.push(`latest request artifact: ${facts.latestRequest}`);
440
+ }
441
+ blocks.push({
442
+ rank: 4,
443
+ heading: REINJECTION_BLOCK_HEADINGS[4] ?? 'current work',
444
+ label: REINJECTION_BLOCK_LABELS[4] ?? 'rank-4',
445
+ lines: current
446
+ });
447
+ return blocks;
448
+ }
449
+ /**
450
+ * Build the card for a project. This is the whole production surface: read
451
+ * the facts, render them under the budget, return. NEVER throws — a thrown
452
+ * error here would surface on session start, which is the one place the
453
+ * absence of this feature must not be able to break anything.
454
+ */
455
+ export function buildPostCompactReinjectionCard(input) {
456
+ const budgetBytes = input.budgetBytes ?? POST_COMPACT_REINJECTION_BYTE_BUDGET;
457
+ let facts;
458
+ try {
459
+ facts = resolvePostCompactReinjectionFacts({
460
+ projectRoot: input.projectRoot,
461
+ sessionId: input.sessionId ?? null
462
+ });
463
+ }
464
+ catch {
465
+ // The resolver is guarded source-by-source; this is the belt-and-braces
466
+ // path for anything unforeseen. An empty card is the correct outcome —
467
+ // the hook then contributes nothing to the context, which is exactly
468
+ // what a session with no state to re-inject should contribute.
469
+ return {
470
+ ok: false,
471
+ text: '',
472
+ bytes: 0,
473
+ budgetBytes,
474
+ sessionId: null,
475
+ emittedRanks: [],
476
+ droppedRanks: [],
477
+ unresolved: ['facts-resolution-threw']
478
+ };
479
+ }
480
+ const card = renderReinjectionCard({ blocks: buildBlocks(facts), budgetBytes });
481
+ return {
482
+ ok: true,
483
+ text: card.text,
484
+ bytes: card.bytes,
485
+ budgetBytes: card.budgetBytes,
486
+ sessionId: facts.sessionId,
487
+ emittedRanks: card.emittedRanks,
488
+ droppedRanks: card.droppedRanks,
489
+ unresolved: facts.unresolved
490
+ };
491
+ }
@@ -21,7 +21,7 @@ function readRegistrations(input) {
21
21
  }
22
22
  function captureConflictDiff(input) {
23
23
  try {
24
- return execFileSync('git', ['diff', '--merge', '--no-color'], { cwd: input.projectRoot, encoding: 'utf8' });
24
+ return execFileSync('git', ['diff', '--merge', '--no-color'], { cwd: input.projectRoot, encoding: 'utf8', windowsHide: true });
25
25
  }
26
26
  catch {
27
27
  return '';
@@ -29,7 +29,7 @@ function captureConflictDiff(input) {
29
29
  }
30
30
  function captureTranscript(input) {
31
31
  try {
32
- return execFileSync('git', ['merge', '--no-edit', '--no-ff', input.agentBranch], { cwd: input.projectRoot, encoding: 'utf8' }).split('\n');
32
+ return execFileSync('git', ['merge', '--no-edit', '--no-ff', input.agentBranch], { cwd: input.projectRoot, encoding: 'utf8', windowsHide: true }).split('\n');
33
33
  }
34
34
  catch {
35
35
  return ['git merge --no-edit --no-ff ' + input.agentBranch];
@@ -61,8 +61,8 @@ export async function runMergeBack(input) {
61
61
  continue;
62
62
  }
63
63
  try {
64
- execFileSync('git', ['checkout', input.callerBranch], { cwd: input.projectRoot, stdio: 'ignore' });
65
- execFileSync(plan.command[0], plan.command.slice(1), { cwd: input.projectRoot, stdio: 'ignore' });
64
+ execFileSync('git', ['checkout', input.callerBranch], { cwd: input.projectRoot, stdio: 'ignore', windowsHide: true });
65
+ execFileSync(plan.command[0], plan.command.slice(1), { cwd: input.projectRoot, stdio: 'ignore', windowsHide: true });
66
66
  return { kind: 'merged', attempts, serviceKills: kills };
67
67
  }
68
68
  catch (error) {
@@ -71,7 +71,7 @@ export async function runMergeBack(input) {
71
71
  const replay = buildConflictReplay({ originalPrompt, mergeAttemptTranscript: transcript, conflictDiff, callerBranch: input.callerBranch });
72
72
  const replayResult = await input.onConflict(replay);
73
73
  try {
74
- execFileSync('git', ['merge', '--abort'], { cwd: input.projectRoot, stdio: 'ignore' });
74
+ execFileSync('git', ['merge', '--abort'], { cwd: input.projectRoot, stdio: 'ignore', windowsHide: true });
75
75
  }
76
76
  catch { /* ignore */ }
77
77
  if (!replayResult.ok) {
@@ -30,16 +30,16 @@ export function killRegisteredServices(input) {
30
30
  }
31
31
  try {
32
32
  if (platform === 'win32') {
33
- execFileSync('taskkill', ['/T', '/F', '/PID', String(reg.pid)], { stdio: 'ignore' });
33
+ execFileSync('taskkill', ['/T', '/F', '/PID', String(reg.pid)], { stdio: 'ignore', windowsHide: true });
34
34
  return { pid: reg.pid, name: reg.name, skipped: false, signal: 'taskkill' };
35
35
  }
36
36
  // POSIX: try SIGTERM via the `kill` CLI (universally available);
37
37
  // escalate to SIGKILL on failure. The runner is best-effort.
38
38
  try {
39
- execFileSync('kill', ['-TERM', String(reg.pid)], { stdio: 'ignore' });
39
+ execFileSync('kill', ['-TERM', String(reg.pid)], { stdio: 'ignore', windowsHide: true });
40
40
  }
41
41
  catch {
42
- execFileSync('kill', ['-KILL', String(reg.pid)], { stdio: 'ignore' });
42
+ execFileSync('kill', ['-KILL', String(reg.pid)], { stdio: 'ignore', windowsHide: true });
43
43
  return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGKILL' };
44
44
  }
45
45
  return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGTERM' };
@@ -91,7 +91,8 @@ export function gitLogSince(projectRoot, since) {
91
91
  const out = execSync(`git log --pretty=format:"%s" ${since}..HEAD`, {
92
92
  cwd: projectRoot,
93
93
  encoding: 'utf8',
94
- stdio: ['ignore', 'pipe', 'ignore']
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ windowsHide: true
95
96
  });
96
97
  if (out.trim().length === 0)
97
98
  return [];
@@ -44,7 +44,7 @@ const defaultRunner = {
44
44
  resolveRun(value);
45
45
  };
46
46
  try {
47
- const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
47
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
48
48
  let stdout = '';
49
49
  child.stdout?.on('data', (chunk) => {
50
50
  stdout += chunk.toString('utf8');
@@ -69,6 +69,7 @@ export async function pickFromList(options) {
69
69
  input: fzfInput,
70
70
  cwd: options.projectRoot,
71
71
  stdio: ['pipe', 'pipe', 'pipe'],
72
+ windowsHide: true,
72
73
  shell: false,
73
74
  timeout: SPAWN_TIMEOUT_MS,
74
75
  maxBuffer: MAX_BUFFER_BYTES
@@ -108,6 +109,7 @@ function checkFzfVersion(fzfBin) {
108
109
  stdout = execFileSync(fzfBin, ['--version'], {
109
110
  stdio: ['ignore', 'pipe', 'pipe'],
110
111
  shell: false,
112
+ windowsHide: true,
111
113
  timeout: 5_000
112
114
  }).toString('utf8');
113
115
  }
@@ -67,6 +67,14 @@ export declare const AUTO_COMPACT_HOOK_MARKER = "peaks:auto-compact-hook-do-not-
67
67
  * Default on-disk location — kept here (not in claude-settings-template)
68
68
  * because the auto-compact hook is a separate install surface from
69
69
  * the fact-forcing gate bypass template.
70
+ *
71
+ * This is Claude Code's path, and it is a DEFAULT only. A caller that
72
+ * resolved an IDE adapter MUST pass the adapter-declared location as
73
+ * `settingsPath`:
74
+ * `join(projectRoot, adapter.settings.dirName, adapter.settings.localSettingsFileName)`.
75
+ * The default remains for callers with no adapter in hand (and keeps the
76
+ * claude-code byte-stability contract); it is not a claim that every IDE
77
+ * stores local settings at `.claude/settings.local.json`.
70
78
  */
71
79
  export declare const AUTO_COMPACT_HOOK_SETTINGS_PATH = ".claude/settings.local.json";
72
80
  /**
@@ -100,7 +108,7 @@ export type AutoCompactHookRemoveResult = {
100
108
  */
101
109
  export declare function installAutoCompactHook(input: {
102
110
  readonly projectRoot: string;
103
- readonly settingsPath?: string;
111
+ readonly settingsPath?: string | undefined;
104
112
  }): AutoCompactHookInstallResult;
105
113
  /**
106
114
  * Remove the auto-compact PreToolUse hook. Idempotent: missing hook
@@ -112,5 +120,5 @@ export declare function installAutoCompactHook(input: {
112
120
  */
113
121
  export declare function removeAutoCompactHook(input: {
114
122
  readonly projectRoot: string;
115
- readonly settingsPath?: string;
123
+ readonly settingsPath?: string | undefined;
116
124
  }): AutoCompactHookRemoveResult;
@@ -70,6 +70,14 @@ export const AUTO_COMPACT_HOOK_MARKER = 'peaks:auto-compact-hook-do-not-edit';
70
70
  * Default on-disk location — kept here (not in claude-settings-template)
71
71
  * because the auto-compact hook is a separate install surface from
72
72
  * the fact-forcing gate bypass template.
73
+ *
74
+ * This is Claude Code's path, and it is a DEFAULT only. A caller that
75
+ * resolved an IDE adapter MUST pass the adapter-declared location as
76
+ * `settingsPath`:
77
+ * `join(projectRoot, adapter.settings.dirName, adapter.settings.localSettingsFileName)`.
78
+ * The default remains for callers with no adapter in hand (and keeps the
79
+ * claude-code byte-stability contract); it is not a claim that every IDE
80
+ * stores local settings at `.claude/settings.local.json`.
73
81
  */
74
82
  export const AUTO_COMPACT_HOOK_SETTINGS_PATH = '.claude/settings.local.json';
75
83
  function isObject(value) {