peaks-loop 4.0.47 → 4.0.49

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 (126) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.js +48 -8
  9. package/dist/cli/commands/compact-command.js +110 -0
  10. package/dist/cli/commands/config-commands.js +15 -9
  11. package/dist/cli/commands/dashboard-long-run.js +6 -0
  12. package/dist/cli/commands/dispatch-commands.js +11 -1
  13. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  14. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  15. package/dist/cli/commands/feedback-commands.js +49 -17
  16. package/dist/cli/commands/final-review-commands.js +12 -0
  17. package/dist/cli/commands/hooks-commands.js +4 -4
  18. package/dist/cli/commands/job-commands.js +8 -0
  19. package/dist/cli/commands/loop-eval-commands.js +31 -0
  20. package/dist/cli/commands/perf-audit-commands.js +2 -0
  21. package/dist/cli/commands/playwright-commands.js +12 -0
  22. package/dist/cli/commands/prd-commands.js +1 -1
  23. package/dist/cli/commands/qa-commands.js +22 -0
  24. package/dist/cli/commands/request-commands.js +8 -0
  25. package/dist/cli/commands/scan-commands.js +1 -1
  26. package/dist/cli/commands/security-audit-commands.js +2 -0
  27. package/dist/cli/commands/slice-integrate-commands.js +22 -0
  28. package/dist/cli/commands/statusline-commands.js +44 -4
  29. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  30. package/dist/cli/commands/sub-agent/detached.js +47 -22
  31. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  32. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  33. package/dist/cli/commands/workflow-commands.js +1 -1
  34. package/dist/cli/index.js +5 -45
  35. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  36. package/dist/services/artifacts/artifact-prerequisites.js +140 -65
  37. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  38. package/dist/services/artifacts/request-artifact-service.js +77 -46
  39. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  40. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  41. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  42. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  43. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  44. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  45. package/dist/services/audit-independent/security-audit-service.js +28 -6
  46. package/dist/services/code/auto-compact-lifecycle.d.ts +194 -0
  47. package/dist/services/code/auto-compact-lifecycle.js +229 -11
  48. package/dist/services/code/auto-compact-orchestrator.js +118 -7
  49. package/dist/services/code/compact-event-settle.d.ts +134 -0
  50. package/dist/services/code/compact-event-settle.js +240 -0
  51. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  52. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  53. package/dist/services/config/config-restore.d.ts +12 -1
  54. package/dist/services/config/config-restore.js +35 -4
  55. package/dist/services/config/config-rollback.js +6 -1
  56. package/dist/services/context/auto-compact-types.d.ts +20 -2
  57. package/dist/services/context/harness-context-witness.d.ts +310 -0
  58. package/dist/services/context/harness-context-witness.js +606 -0
  59. package/dist/services/evidence/evidence-generator.js +86 -49
  60. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  61. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  62. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  63. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  64. package/dist/services/final-review/final-review-service.d.ts +9 -0
  65. package/dist/services/final-review/final-review-service.js +36 -12
  66. package/dist/services/ide/ide-registry.d.ts +19 -0
  67. package/dist/services/ide/ide-registry.js +21 -0
  68. package/dist/services/job/job-progress-store.js +18 -3
  69. package/dist/services/job/job-state-store.js +7 -0
  70. package/dist/services/observability/jsonl-store.d.ts +19 -0
  71. package/dist/services/observability/jsonl-store.js +27 -2
  72. package/dist/services/observability/observability-service.d.ts +10 -3
  73. package/dist/services/observability/observability-service.js +16 -3
  74. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  75. package/dist/services/prd/handoff-auto-regen.js +31 -27
  76. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  77. package/dist/services/prd/handoff-frontmatter.js +75 -0
  78. package/dist/services/prd/handoff-service.d.ts +41 -2
  79. package/dist/services/prd/handoff-service.js +124 -8
  80. package/dist/services/prd/handoff-types.d.ts +3 -2
  81. package/dist/services/prd/handoff-types.js +3 -2
  82. package/dist/services/qa/qa-business-review-state.js +23 -0
  83. package/dist/services/sc/sc-service.d.ts +8 -0
  84. package/dist/services/sc/sc-service.js +8 -1
  85. package/dist/services/scan/karpathy-service.js +2 -2
  86. package/dist/services/session/getSessionDir.d.ts +33 -0
  87. package/dist/services/session/getSessionDir.js +60 -0
  88. package/dist/services/session/session-checkpoint-service.js +8 -0
  89. package/dist/services/skill/resume-detector.js +29 -11
  90. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  91. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  92. package/dist/services/skills/hooks-settings-service.js +14 -4
  93. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  94. package/dist/services/skills/session-start-hook-constants.js +45 -0
  95. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  96. package/dist/services/slice/slice-check-service.js +29 -11
  97. package/dist/services/slice/slice-review-state.js +23 -0
  98. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  99. package/dist/services/workflow/pipeline-verify-gate-support.js +221 -103
  100. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  101. package/dist/services/workflow/pipeline-verify-service.js +47 -33
  102. package/dist/services/workflow/pipeline-verify-types.d.ts +15 -6
  103. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  104. package/dist/services/workspace/claude-settings-template.js +98 -20
  105. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  106. package/dist/shared/runtime-root.d.ts +73 -0
  107. package/dist/shared/runtime-root.js +77 -0
  108. package/package.json +6 -6
  109. package/skills/bee/peaks-prd/SKILL.md +7 -5
  110. package/skills/bee/peaks-qa/SKILL.md +5 -5
  111. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  112. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  113. package/skills/bee/peaks-rd/SKILL.md +8 -6
  114. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  115. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  116. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  117. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  118. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  119. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  120. package/skills/peaks-code/SKILL.md +1 -1
  121. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  122. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  123. package/skills/peaks-code/references/resume-detection.md +13 -7
  124. package/skills/peaks-code/references/runbook.md +3 -2
  125. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  126. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -1 +1,34 @@
1
1
  export declare function getSessionDir(projectRoot: string, sessionId: string): string;
2
+ /**
3
+ * The TOTAL entry to the same axis. Same predicate, same path; the only
4
+ * difference is that this one is total — it never throws.
5
+ *
6
+ * WHY TWO ENTRIES RATHER THAN ONE. The partial entry above is correct
7
+ * for a caller that cannot proceed without a session dir: a throw is
8
+ * the one failure a caller cannot forget to handle. It is the WRONG
9
+ * shape for a frame whose own doc promises never to throw — the
10
+ * statusline, the fire-and-forget telemetry writer, the best-effort
11
+ * probe. Each of those frames had to wrap the partial entry in a
12
+ * `try { } catch { return null }`, and that swallow cannot be told
13
+ * apart from a real "there is nothing here" answer. Measured on the
14
+ * compact backoff (`auto-compact-lifecycle.ts`), the conflation turned
15
+ * an unresolvable id into the ADMIT branch and re-opened a dispatch
16
+ * that the open run should have suppressed.
17
+ *
18
+ * The split does NOT make a swallow unwriteable — TypeScript has no
19
+ * checked exceptions, so nothing here can. It makes the swallow
20
+ * UNNECESSARY at a named place, and it gives every checker a stable
21
+ * name to key on: a frame that degrades must say so by calling this
22
+ * function, and the degrading branch (`ok: false`) is then a value the
23
+ * caller has to handle rather than a `catch` nobody reads.
24
+ *
25
+ * `reason` is a single-line English sentence fit for an envelope — no
26
+ * stack traces, no CLI verbs (see `human-nl-choice-only-tenet`).
27
+ */
28
+ export declare function tryGetSessionDir(projectRoot: string, sessionId: string): {
29
+ readonly ok: true;
30
+ readonly dir: string;
31
+ } | {
32
+ readonly ok: false;
33
+ readonly reason: string;
34
+ };
@@ -22,11 +22,71 @@
22
22
  * legacy `.peaks/<sid>/...` artifact path that a sub-agent would
23
23
  * follow verbatim.
24
24
  *
25
+ * `sessionId` is the last caller-supplied segment of every session-scoped
26
+ * path here, so its segment check lives at this join rather than being
27
+ * re-derived at each call site: `../../x` used to be joined verbatim, and
28
+ * the CLI wrote outside the project root while still returning `ok: true`.
29
+ *
30
+ * The predicate is `isUnsafePathInput`, NOT `SESSION_ID_PATTERN` /
31
+ * `validateSessionId`. The latter are stricter than the segment axis and
32
+ * reject ids that are legal today (`sid-1`, a request id reused as the
33
+ * session-dir name), so adopting them would change results for
34
+ * well-formed callers.
35
+ *
36
+ * Not covered, recorded rather than implied: the `\0` axis
37
+ * (`isUnsafePathInput` admits a NUL, and `execFileSync` raises EINVAL
38
+ * before it can be exercised), and callers that hand-roll
39
+ * `join(root, '.peaks', '_runtime', sid, ...)` instead of calling this.
40
+ *
25
41
  * @param projectRoot - Absolute path to the project root.
26
42
  * @param sessionId - The session identifier (e.g. `2026-06-06-session-5b1095`).
27
43
  * @returns Absolute path to the canonical session directory.
44
+ * @throws Error when `sessionId` is not a single path segment.
28
45
  */
29
46
  import { join } from 'node:path';
47
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
30
48
  export function getSessionDir(projectRoot, sessionId) {
49
+ // Throwing, not `null` / a tagged result: those widen the return type
50
+ // to `string | null` and make every call site handle a bad id — the
51
+ // per-site slice this guard replaces. This is also the shape the repo
52
+ // already refuses with, and a caller cannot forget to handle a throw.
53
+ if (isUnsafePathInput(sessionId)) {
54
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
55
+ }
31
56
  return join(projectRoot, '.peaks', '_runtime', sessionId);
32
57
  }
58
+ /**
59
+ * The TOTAL entry to the same axis. Same predicate, same path; the only
60
+ * difference is that this one is total — it never throws.
61
+ *
62
+ * WHY TWO ENTRIES RATHER THAN ONE. The partial entry above is correct
63
+ * for a caller that cannot proceed without a session dir: a throw is
64
+ * the one failure a caller cannot forget to handle. It is the WRONG
65
+ * shape for a frame whose own doc promises never to throw — the
66
+ * statusline, the fire-and-forget telemetry writer, the best-effort
67
+ * probe. Each of those frames had to wrap the partial entry in a
68
+ * `try { } catch { return null }`, and that swallow cannot be told
69
+ * apart from a real "there is nothing here" answer. Measured on the
70
+ * compact backoff (`auto-compact-lifecycle.ts`), the conflation turned
71
+ * an unresolvable id into the ADMIT branch and re-opened a dispatch
72
+ * that the open run should have suppressed.
73
+ *
74
+ * The split does NOT make a swallow unwriteable — TypeScript has no
75
+ * checked exceptions, so nothing here can. It makes the swallow
76
+ * UNNECESSARY at a named place, and it gives every checker a stable
77
+ * name to key on: a frame that degrades must say so by calling this
78
+ * function, and the degrading branch (`ok: false`) is then a value the
79
+ * caller has to handle rather than a `catch` nobody reads.
80
+ *
81
+ * `reason` is a single-line English sentence fit for an envelope — no
82
+ * stack traces, no CLI verbs (see `human-nl-choice-only-tenet`).
83
+ */
84
+ export function tryGetSessionDir(projectRoot, sessionId) {
85
+ // Deliberately NOT `try { return {ok:true, dir: getSessionDir(...)} } catch`.
86
+ // That would re-introduce the swallow this function exists to remove, and it
87
+ // would also catch a throw from `join` for reasons that are not a bad id.
88
+ if (isUnsafePathInput(sessionId)) {
89
+ return { ok: false, reason: `Invalid session id: ${sessionId} (must be a single path segment)` };
90
+ }
91
+ return { ok: true, dir: join(projectRoot, '.peaks', '_runtime', sessionId) };
92
+ }
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
13
  import { join, sep } from 'node:path';
14
14
  import { emitObservabilityEvent } from '../observability/observability-service.js';
15
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
15
16
  const CHECKPOINTS_DIR = 'checkpoints';
16
17
  const CHECKPOINT_FILENAME_EXT = '.json';
17
18
  const MAX_CHECKPOINTS = 10;
@@ -76,6 +77,13 @@ function pruneOldest(dir) {
76
77
  return removed;
77
78
  }
78
79
  export function writeCheckpoint(projectRoot, options) {
80
+ // Sid axis, placed before the first read and the first `mkdir`. Both
81
+ // `peaks session checkpoint --session-id` and `peaks compact force
82
+ // --session-id` reach this same function, so one guard closes both measured
83
+ // surfaces — they were never two joins.
84
+ if (isUnsafePathInput(options.sessionId)) {
85
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
86
+ }
79
87
  const now = (options.now ?? (() => new Date()))();
80
88
  const createdAt = now.toISOString();
81
89
  const lastActivity = readSessionLastActivity(projectRoot, options.sessionId) || createdAt;
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
+ import { readArtifactState } from '../artifacts/request-artifact-state-helpers.js';
35
36
  const MID_IMPL_RD_STATES = new Set([
36
37
  'spec-locked',
37
38
  'implemented',
@@ -192,13 +193,21 @@ function classifyTerminalGates(sessionDir, ctx) {
192
193
  // RD qa-handoff → deepest gate is C. If the review artifacts are
193
194
  // missing the state is inconsistent; fall back to rd-review-fanout.
194
195
  if (ctx.primaryRd !== null && ctx.primaryRd.state === 'qa-handoff') {
195
- const codeReviewPath = join(sessionDir, 'rd', 'code-review.md');
196
- const securityReviewPath = join(sessionDir, 'rd', 'security-review.md');
196
+ // Slice `2026-09-14-audit-artifact-rid-scoping`: the fan-out evidence
197
+ // filenames carry the rid. Probe the canonical rid-scoped name first and
198
+ // the pre-rid names behind it — the same order the transition gate
199
+ // resolves in, so an inconsistent-looking slice here means the fan-out
200
+ // never ran, not that it wrote to a name this reader does not know.
201
+ const rid = ridOf(ctx.primaryRd.filename);
197
202
  const missing = [];
198
- if (!existsSync(codeReviewPath))
199
- missing.push('rd/code-review.md');
200
- if (!existsSync(securityReviewPath))
201
- missing.push('rd/security-review.md');
203
+ const codeReviewCandidates = [`rd/code-review-${rid}.md`, 'rd/code-review.md'];
204
+ const securityCandidates = [`audit/security-${rid}.md`, 'audit/security.md', 'rd/security-review.md'];
205
+ if (!codeReviewCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
206
+ missing.push(codeReviewCandidates[0]);
207
+ }
208
+ if (!securityCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
209
+ missing.push(securityCandidates[0]);
210
+ }
202
211
  if (missing.length > 0) {
203
212
  return {
204
213
  kind: 'resume',
@@ -317,12 +326,21 @@ function readRequestStates(sessionDir, role) {
317
326
  };
318
327
  });
319
328
  }
329
+ /** Empty string (not 'unknown') when the artifact has no state line — `primaryPrd.state.length > 0` below depends on that. */
320
330
  function extractState(content) {
321
- const match = /^-\s*state:\s*(\S+)|^state:\s*(\S+)/m.exec(content);
322
- if (match === null)
323
- return '';
324
- const captured = match[1] ?? match[2] ?? '';
325
- return captured.trim();
331
+ return readArtifactState(content) ?? '';
332
+ }
333
+ /**
334
+ * The `<rid>` embedded in a request filename: drop the `.md` suffix and the
335
+ * zero-padded `NNN-` prefix `request init` writes.
336
+ *
337
+ * Uses the same `^0\d{2}-` rule as the request loader
338
+ * (`request-artifact-service.ts`: "Only strip 3-digit zero-padded prefixes").
339
+ * A looser `^\d+-` would eat the leading year of every real rid, which is
340
+ * itself date-shaped (`2026-09-14-<slug>`).
341
+ */
342
+ function ridOf(filename) {
343
+ return filename.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
326
344
  }
327
345
  function hasAbandonedTransitionNote(content) {
328
346
  return /user-requested-abandon/.test(content);
@@ -39,6 +39,12 @@ interface ResolvedHookSpec {
39
39
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
40
40
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
41
41
  * everywhere else.
42
+ *
43
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
44
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
45
+ * mechanism is believed absent there — see the block comment in
46
+ * `resolveHookEntries` for what is and is not established, and for why adding
47
+ * the pin in place would break every non-Windows teammate.
42
48
  */
43
49
  export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
44
50
  /**
@@ -8,7 +8,7 @@
8
8
  * type contract re-exported by the caller.
9
9
  */
10
10
  import { getAdapter } from '../ide/ide-registry.js';
11
- import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_POST_COMPACT_REINJECT_COMMAND, HOOK_POST_COMPACT_REINJECT_EVENT, HOOK_POST_COMPACT_REINJECT_MATCHER, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
11
+ import { HOOK_COMPACT_SETTLE_COMMAND, HOOK_COMPACT_SETTLE_EVENT, HOOK_COMPACT_SETTLE_MATCHER, HOOK_COMPACT_SETTLE_SENTINEL, HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_POST_COMPACT_REINJECT_COMMAND, HOOK_POST_COMPACT_REINJECT_EVENT, HOOK_POST_COMPACT_REINJECT_MATCHER, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
12
12
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
13
13
  export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
14
14
  /**
@@ -20,6 +20,12 @@ export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
20
20
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
21
21
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
22
22
  * everywhere else.
23
+ *
24
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
25
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
26
+ * mechanism is believed absent there — see the block comment in
27
+ * `resolveHookEntries` for what is and is not established, and for why adding
28
+ * the pin in place would break every non-Windows teammate.
23
29
  */
24
30
  export function resolveHookShell(platform = process.platform) {
25
31
  return platform === 'win32' ? 'powershell' : undefined;
@@ -112,6 +118,37 @@ export function resolveHookEntries(ide, _skipProgress = false) {
112
118
  ...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
113
119
  }
114
120
  ];
121
+ // ── Why the three SessionStart entries below carry NO `shell` pin ─────────
122
+ //
123
+ // The gate-enforce entry above is shell-pinned on Windows (see
124
+ // `resolveHookShell`) because Claude Code runs a shell-form hook command
125
+ // through a shell that defaults to bash — Git Bash / MSYS2 on Windows — and
126
+ // MSYS2 bash force-allocates its own console window. That reason is a
127
+ // property of the hook RUNNER's shell resolution, not of the `PreToolUse`
128
+ // event: nothing in it exempts `SessionStart`, so the same command-form
129
+ // entry on this event goes through the same shell. The pin was applied only
130
+ // to the `Bash`-matcher handlers because those were the ones the reporter
131
+ // could see (they run on EVERY Bash tool call); these three run once per
132
+ // session, so a window here — if there is one — is a single flash rather
133
+ // than a per-tool-call nuisance. That is a difference in frequency, not
134
+ // evidence that the window does not appear, and no A/B measurement on this
135
+ // event exists.
136
+ //
137
+ // The pin is nonetheless NOT applied here, and adding it would be a defect
138
+ // rather than a fix: `shell: "powershell"` resolves to `pwsh`, these entries
139
+ // are written to the COMMITTED `.claude/settings.json`, and a macOS / Linux
140
+ // teammate reading that file has no `pwsh` — the exact cross-platform damage
141
+ // the machine-local split exists to prevent (see the gate-enforce entry's
142
+ // `machineLocal` flag, and commit 4637baa8's rationale).
143
+ //
144
+ // A correct fix therefore has to move these three entries to the
145
+ // machine-local file as well, which changes where a fresh clone gets its
146
+ // SessionStart hooks: today they arrive with the repository, after such a
147
+ // change they would require `peaks hooks install` / `peaks workspace init`.
148
+ // That is a product-shape decision, not a leftover; it is recorded here so
149
+ // that whoever makes it starts from the reason the pin is absent instead of
150
+ // re-deriving it — or, worse, adding the pin in place and breaking every
151
+ // non-Windows teammate.
115
152
  if (ide === 'claude-code') {
116
153
  entries.push({
117
154
  sentinel: HOOK_OUTER_CACHE_SENTINEL,
@@ -161,6 +198,24 @@ export function resolveHookEntries(ide, _skipProgress = false) {
161
198
  command: HOOK_POST_COMPACT_REINJECT_COMMAND,
162
199
  event: HOOK_POST_COMPACT_REINJECT_EVENT
163
200
  });
201
+ // rid 2026-09-13-compact-event-settle: the harness's OWN "a compaction
202
+ // completed" event, which is the only signal that settles a compact as a
203
+ // FACT rather than as an inference from a ratio that fell.
204
+ //
205
+ // No `machineLocal` flag, so this lands in the shared, committed
206
+ // `.claude/settings.json` — the same file as the three SessionStart entries
207
+ // above, and NOT the machine-local file the workspace-init materializer
208
+ // owns. That routing IS the answer to "who owns the `hooks` key for this
209
+ // entry": the only writer that could delete it is the one that installed
210
+ // it. See `session-start-hook-constants.ts` for why it also carries no
211
+ // `shell` pin, and `mergeHooksTree` for the second line of defence if it
212
+ // ever moves.
213
+ entries.push({
214
+ sentinel: HOOK_COMPACT_SETTLE_SENTINEL,
215
+ matcher: HOOK_COMPACT_SETTLE_MATCHER,
216
+ command: HOOK_COMPACT_SETTLE_COMMAND,
217
+ event: HOOK_COMPACT_SETTLE_EVENT
218
+ });
164
219
  }
165
220
  return entries;
166
221
  }
@@ -191,7 +246,11 @@ export function resolveLegacySentinels(ide) {
191
246
  // it) exactly like the other two SessionStart entries. Without this the
192
247
  // entry would be unremovable by `peaks hooks uninstall` — the rollback
193
248
  // path T2 requires.
194
- return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL];
249
+ // rid 2026-09-13-compact-event-settle: the PostCompact settle sentinel joins
250
+ // too — same rollback argument as the reinject entry above. A hook that
251
+ // `peaks hooks uninstall` cannot remove is a hook the user cannot get rid
252
+ // of, and this one fires on every compaction.
253
+ return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_COMPACT_SETTLE_SENTINEL];
195
254
  }
196
255
  return base;
197
256
  }
@@ -322,10 +322,20 @@ function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
322
322
  return false;
323
323
  }
324
324
  }
325
- // (b) every desired entry must be on disk.
326
- for (const sentinel of desiredSentinels) {
327
- const has = peaksPresent.some((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(sentinel)));
328
- if (!has)
325
+ // (b) every desired entry must be on disk AND carry the matcher it declares.
326
+ // Presence alone cannot see a WRONG matcher, and a wrong matcher is not
327
+ // cosmetic: `''` and `Bash|Task` route the same command to different
328
+ // tool sets, so the entry is present while the hook never fires on the
329
+ // tools it was installed for. Measured (rid `2026-09-13-compact-event-settle`,
330
+ // residual R2): a `PostCompact` entry hand-corrupted to matcher
331
+ // `auto|manual` survived `peaks hooks install` unchanged, and the
332
+ // installer was structurally unable to repair it.
333
+ // An absent matcher reads as `''` — the form Claude Code takes as "every
334
+ // source" — so a file written before matchers were explicit converges
335
+ // once instead of churning on every install.
336
+ for (const desired of entries.filter((e) => e.event === eventKey)) {
337
+ const onDisk = peaksPresent.find((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(desired.sentinel)));
338
+ if (onDisk === undefined || (onDisk.matcher ?? '') !== desired.matcher)
329
339
  return false;
330
340
  }
331
341
  }
@@ -83,3 +83,48 @@ export declare const HOOK_WORKSPACE_INIT_SENTINEL = "peaks session primer";
83
83
  export declare const HOOK_WORKSPACE_INIT_COMMAND = "peaks session primer --project \"${CLAUDE_PROJECT_DIR}\"";
84
84
  /** SessionStart hook event key (same as outer-cache). */
85
85
  export declare const HOOK_WORKSPACE_INIT_EVENT = "SessionStart";
86
+ /**
87
+ * rid `2026-09-13-compact-event-settle` — the `PostCompact` entry that lets the
88
+ * harness's own event settle a compact, instead of the next `context-now` probe
89
+ * inferring one from a ratio that fell.
90
+ *
91
+ * WHY THIS ENTRY IS NOT A `SessionStart` ONE, despite living in this file: the
92
+ * three entries above all ride `SessionStart` and differ only by matcher. A
93
+ * `PostCompact` hook is a different EVENT that carries the one fact no
94
+ * `SessionStart` payload has — whether the compaction the harness just
95
+ * completed was `auto` or `manual`. That distinction is the whole question
96
+ * ("has this machine ever auto-compacted?"), and without it peaks-loop can only
97
+ * ever see that SOMETHING compacted. See `compact-event-settle.ts` for what the
98
+ * command does with it.
99
+ *
100
+ * WHY THE MATCHER IS THE EMPTY STRING and not the documented `auto|manual`:
101
+ * both trigger values are wanted, so the matcher must filter nothing. An empty
102
+ * matcher is the convention the three `SessionStart` entries already rely on to
103
+ * match every source, and it is the only form that cannot fail SILENTLY — an
104
+ * alternation string is a match-everything pattern under regex semantics but
105
+ * matches NEITHER value under exact-equality semantics, and a hook that never
106
+ * fires looks exactly like a hook with nothing to report.
107
+ */
108
+ export declare const HOOK_COMPACT_SETTLE_SENTINEL = "peaks compact settle";
109
+ /**
110
+ * The settle hook command. `--project "${CLAUDE_PROJECT_DIR}"` is byte-for-byte
111
+ * the shape of the three `SessionStart` entries above — Claude Code's standard
112
+ * project-root convention, resolved strictly on the CLI side (a hook payload is
113
+ * env-driven and must not be trusted as a path).
114
+ *
115
+ * The command prints NOTHING on the hook path and exits 0 for every outcome.
116
+ * `PostCompact`'s stdin/stdout contract is truncated in the retrievable docs,
117
+ * so the safe assumption is the `SessionStart` one — stdout may be added to the
118
+ * model's context. An error message there would be read as a fact. See
119
+ * `compact-event-settle.ts`.
120
+ *
121
+ * No `shell` pin, deliberately: this entry lands in the shared, committed
122
+ * `.claude/settings.json`, and a `powershell` pin there would break every
123
+ * macOS / Linux reader of the file. See `resolveHookEntries`' comment block for
124
+ * the full reason the three `SessionStart` entries are unpinned too.
125
+ */
126
+ export declare const HOOK_COMPACT_SETTLE_COMMAND = "peaks compact settle --project \"${CLAUDE_PROJECT_DIR}\"";
127
+ /** The event this entry rides. Claude Code fires it after a compaction completes. */
128
+ export declare const HOOK_COMPACT_SETTLE_EVENT = "PostCompact";
129
+ /** Matcher: empty = every `trigger` (`auto` and `manual`). See the sentinel doc. */
130
+ export declare const HOOK_COMPACT_SETTLE_MATCHER = "";
@@ -83,3 +83,48 @@ export const HOOK_WORKSPACE_INIT_SENTINEL = 'peaks session primer';
83
83
  export const HOOK_WORKSPACE_INIT_COMMAND = `peaks session primer --project "\${CLAUDE_PROJECT_DIR}"`;
84
84
  /** SessionStart hook event key (same as outer-cache). */
85
85
  export const HOOK_WORKSPACE_INIT_EVENT = 'SessionStart';
86
+ /**
87
+ * rid `2026-09-13-compact-event-settle` — the `PostCompact` entry that lets the
88
+ * harness's own event settle a compact, instead of the next `context-now` probe
89
+ * inferring one from a ratio that fell.
90
+ *
91
+ * WHY THIS ENTRY IS NOT A `SessionStart` ONE, despite living in this file: the
92
+ * three entries above all ride `SessionStart` and differ only by matcher. A
93
+ * `PostCompact` hook is a different EVENT that carries the one fact no
94
+ * `SessionStart` payload has — whether the compaction the harness just
95
+ * completed was `auto` or `manual`. That distinction is the whole question
96
+ * ("has this machine ever auto-compacted?"), and without it peaks-loop can only
97
+ * ever see that SOMETHING compacted. See `compact-event-settle.ts` for what the
98
+ * command does with it.
99
+ *
100
+ * WHY THE MATCHER IS THE EMPTY STRING and not the documented `auto|manual`:
101
+ * both trigger values are wanted, so the matcher must filter nothing. An empty
102
+ * matcher is the convention the three `SessionStart` entries already rely on to
103
+ * match every source, and it is the only form that cannot fail SILENTLY — an
104
+ * alternation string is a match-everything pattern under regex semantics but
105
+ * matches NEITHER value under exact-equality semantics, and a hook that never
106
+ * fires looks exactly like a hook with nothing to report.
107
+ */
108
+ export const HOOK_COMPACT_SETTLE_SENTINEL = 'peaks compact settle';
109
+ /**
110
+ * The settle hook command. `--project "${CLAUDE_PROJECT_DIR}"` is byte-for-byte
111
+ * the shape of the three `SessionStart` entries above — Claude Code's standard
112
+ * project-root convention, resolved strictly on the CLI side (a hook payload is
113
+ * env-driven and must not be trusted as a path).
114
+ *
115
+ * The command prints NOTHING on the hook path and exits 0 for every outcome.
116
+ * `PostCompact`'s stdin/stdout contract is truncated in the retrievable docs,
117
+ * so the safe assumption is the `SessionStart` one — stdout may be added to the
118
+ * model's context. An error message there would be read as a fact. See
119
+ * `compact-event-settle.ts`.
120
+ *
121
+ * No `shell` pin, deliberately: this entry lands in the shared, committed
122
+ * `.claude/settings.json`, and a `powershell` pin there would break every
123
+ * macOS / Linux reader of the file. See `resolveHookEntries`' comment block for
124
+ * the full reason the three `SessionStart` entries are unpinned too.
125
+ */
126
+ export const HOOK_COMPACT_SETTLE_COMMAND = `peaks compact settle --project "\${CLAUDE_PROJECT_DIR}"`;
127
+ /** The event this entry rides. Claude Code fires it after a compaction completes. */
128
+ export const HOOK_COMPACT_SETTLE_EVENT = 'PostCompact';
129
+ /** Matcher: empty = every `trigger` (`auto` and `manual`). See the sentinel doc. */
130
+ export const HOOK_COMPACT_SETTLE_MATCHER = '';
@@ -7,6 +7,20 @@ export type StatusLineStdin = {
7
7
  cwd?: string;
8
8
  session_id?: string;
9
9
  caller_id?: string;
10
+ /**
11
+ * The harness's own context numbers. Declared here because this type IS the
12
+ * documented shape of the payload the harness pipes in; omitting a documented
13
+ * field would make the type lie by omission, and a consumer that reached for
14
+ * it would have to cast. Read (never written) by
15
+ * `harness-context-witness.ts` — see that module for why
16
+ * `context_window_size` is NOT a denominator.
17
+ */
18
+ context_window?: {
19
+ context_window_size?: unknown;
20
+ used_percentage?: unknown;
21
+ remaining_percentage?: unknown;
22
+ current_usage?: Record<string, unknown> | undefined;
23
+ } | undefined;
10
24
  };
11
25
  export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
12
26
  export type StatusLinePresence = {
@@ -8,6 +8,7 @@ import { isDirectory } from 'peaks-loop-shared/fs';
8
8
  // is no longer read. Path-safety helpers now live at
9
9
  // `shared/path-safety.ts` if this module ever needs them.
10
10
  import { verifyPipeline } from '../workflow/pipeline-verify-service.js';
11
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
11
12
  import { findMockViolations } from '../audit/enforcers/mock-placement.js';
12
13
  import { runRedLinesAudit } from '../audit/red-lines-service.js';
13
14
  /**
@@ -239,20 +240,29 @@ async function runUnitTests(projectRoot, runTests) {
239
240
  }
240
241
  };
241
242
  }
243
+ // Slice `2026-09-14-audit-artifact-rid-scoping`: the evidence filenames
244
+ // carry the rid now. Candidate order is canonical-first, then the
245
+ // back-compat tiers — the same order `artifact-prerequisites.ts` resolves
246
+ // in, so this boundary gate and the transition gate agree on which file is
247
+ // a slice's evidence. `<rid>` is substituted at probe time.
242
248
  const REVIEW_FILES = [
243
- { name: 'code-review', path: 'rd/code-review.md', label: 'code-review' },
249
+ {
250
+ name: 'code-review',
251
+ paths: ['rd/code-review-<rid>.md', 'rd/code-review.md'],
252
+ label: 'code-review'
253
+ },
244
254
  // v2.12.0 collapse: security + perf moved to standalone audit skills.
245
- // `slice check` accepts EITHER the v2.11.x legacy path OR the v2.12.0
246
- // canonical audit path during the 1-minor-release back-compat window
247
- // (v2.13.0 hard-deletes the legacy paths — see CHANGELOG [2.12.0]).
255
+ // `slice check` accepts the rid-scoped audit path, the bare v2.12.0 path
256
+ // OR the v2.11.x legacy path during the 1-minor-release back-compat
257
+ // window (v2.13.0 hard-deletes the legacy paths — see CHANGELOG [2.12.0]).
248
258
  {
249
259
  name: 'security-review',
250
- paths: ['audit/security.md', 'rd/security-review.md'],
260
+ paths: ['audit/security-<rid>.md', 'audit/security.md', 'rd/security-review.md'],
251
261
  label: 'security-review'
252
262
  },
253
263
  {
254
264
  name: 'perf-baseline',
255
- paths: ['audit/perf.md', 'rd/perf-baseline.md'],
265
+ paths: ['audit/perf-<rid>.md', 'audit/perf.md', 'rd/perf-baseline.md'],
256
266
  label: 'perf-baseline'
257
267
  }
258
268
  ];
@@ -284,11 +294,11 @@ async function runReviewFanout(projectRoot, rid, refresh) {
284
294
  const found = [];
285
295
  for (const review of REVIEW_FILES) {
286
296
  let hit = null;
287
- // v2.12.0 back-compat: each entry may list multiple candidate paths.
288
- // First hit (in declared order) wins; canonical paths come first so
289
- // v2.12.0+ slices preferentially report the new path even when both
290
- // are present during migration.
291
- const candidates = 'path' in review ? [review.path] : review.paths;
297
+ // Back-compat: each entry lists multiple candidate paths. First hit (in
298
+ // declared order) wins; canonical paths come first so a current slice
299
+ // preferentially reports its own rid-scoped evidence even when a legacy
300
+ // file is also present during migration.
301
+ const candidates = review.paths.map((candidate) => candidate.replace('<rid>', rid));
292
302
  for (const candidate of candidates) {
293
303
  for (const scope of scopes) {
294
304
  const abs = join(projectRoot, '.peaks', scope, candidate);
@@ -367,6 +377,14 @@ export async function sliceCheck(options) {
367
377
  throw new Error('No --rid supplied. Pass --rid <id> on the CLI to identify which slice to check.');
368
378
  }
369
379
  const rid = options.rid;
380
+ // The rid becomes a filename in `runReviewFanout`'s candidate list and a
381
+ // directory segment in its `scopes` — both joined onto the project root. The
382
+ // rid is the repo's own request-id shape, and `request-artifact-service.ts`
383
+ // already refuses anything else; apply the same guard here rather than
384
+ // probing paths a hostile `--rid` steers.
385
+ if (!REQUEST_ID_PATTERN.test(rid)) {
386
+ throw new Error(`Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`);
387
+ }
370
388
  const totalStart = Date.now();
371
389
  const stages = [];
372
390
  let unitTestsRunMode = 'skipped';
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
18
+ import { SLICE_ID_PATTERN } from '../sc/sc-service.js';
17
19
  /** The 5 default review items per slice (the 12 Gaps memory checklist). */
18
20
  export const DEFAULT_REVIEW_ITEMS = [
19
21
  { id: 'business-match', question: '这个 slice 做完,业务流程对吗?(跟产品最初给的需求匹配)' },
@@ -32,9 +34,30 @@ export function buildEmptySliceReview(sliceId, sessionId, now = new Date()) {
32
34
  };
33
35
  }
34
36
  export function getReviewDir(projectRoot, sessionId) {
37
+ // Sid axis ONLY. Every `peaks slice-review|score|accept|reject` subcommand
38
+ // reaches the runtime tree through this one constructor. Measured:
39
+ // `--session-id ../../../../…/PWNED` wrote `slice-reviews/<slice-id>.json`
40
+ // outside every project root under an `ok: true` envelope (RD sweep case A26).
41
+ //
42
+ // It does NOT cover the slice-id axis: the slice id is joined one function
43
+ // later, in `getReviewPath` below. Corrected 2026-09-14 (repair R1) — this
44
+ // comment previously said one guard covered the whole family; the security
45
+ // audit of `2026-09-14-cli-id-escape-instrumentation` (F1b) measured that
46
+ // false: `slice-review '../../../../…/EVILSL'` wrote a `.json` file outside
47
+ // the project root under `ok: true`.
48
+ if (isUnsafePathInput(sessionId)) {
49
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
50
+ }
35
51
  return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'slice-reviews');
36
52
  }
37
53
  export function getReviewPath(projectRoot, sessionId, sliceId) {
54
+ // Slice-id axis. The slice id is the CLI positional (`slice-review
55
+ // <slice-id>`), so it is caller-supplied and it becomes a filename segment
56
+ // here. Same control as the rid axis: a pinned format beats the segment check,
57
+ // which admits `a/b`.
58
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
59
+ throw new Error(`Invalid slice id: ${sliceId} (expected letters, digits, dots, underscores, or dashes)`);
60
+ }
38
61
  return join(getReviewDir(projectRoot, sessionId), `${sliceId}.json`);
39
62
  }
40
63
  export function readSliceReview(projectRoot, sessionId, sliceId) {