peaks-loop 4.0.48 → 4.0.50

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 (156) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/compact-command.js +1 -3
  7. package/dist/cli/commands/core/skill-command.js +53 -4
  8. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  9. package/dist/cli/commands/core/standards-command.js +74 -0
  10. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  11. package/dist/cli/commands/feedback-commands.js +49 -17
  12. package/dist/cli/commands/final-review-commands.js +12 -0
  13. package/dist/cli/commands/hooks-commands.js +55 -38
  14. package/dist/cli/commands/loop-eval-commands.js +22 -6
  15. package/dist/cli/commands/share-commands.js +37 -11
  16. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  17. package/dist/cli/commands/web-commands.js +8 -1
  18. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  19. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  20. package/dist/services/adapter/adapter.d.ts +30 -0
  21. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  22. package/dist/services/adapter/claude-adapter.js +12 -0
  23. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  24. package/dist/services/adapter/codex-adapter.js +12 -0
  25. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  26. package/dist/services/adapter/copilot-adapter.js +12 -0
  27. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  28. package/dist/services/artifacts/request-artifact-service.js +59 -38
  29. package/dist/services/audit/backing-detector.d.ts +25 -7
  30. package/dist/services/audit/backing-detector.js +33 -17
  31. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  32. package/dist/services/audit/enforcer-liveness.js +100 -0
  33. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  34. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  35. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  36. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  37. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  38. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  39. package/dist/services/audit/enforcers/lint-style.js +38 -2
  40. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  41. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  42. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  43. package/dist/services/audit/red-lines-service.js +51 -7
  44. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  45. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  46. package/dist/services/capability-audit-service/index.d.ts +3 -1
  47. package/dist/services/capability-audit-service/index.js +1 -0
  48. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  49. package/dist/services/capability-audit-service/runner.js +76 -15
  50. package/dist/services/capability-audit-service/types.d.ts +48 -0
  51. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  52. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  53. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  54. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  55. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  56. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  57. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  58. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  59. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  60. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  61. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  62. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  63. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  64. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  65. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  66. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  67. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  68. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  69. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  70. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  71. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  72. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  73. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  74. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  75. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  76. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  77. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  78. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  79. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  80. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  81. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  82. package/dist/services/capability-guard-runner/registry.js +140 -0
  83. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  84. package/dist/services/capability-guard-runner/runner.js +63 -6
  85. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  86. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  87. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  88. package/dist/services/code/auto-compact-modes.js +20 -4
  89. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  90. package/dist/services/code/compact-event-settle.d.ts +20 -8
  91. package/dist/services/code/compact-event-settle.js +21 -0
  92. package/dist/services/code/post-compact-detector.js +20 -11
  93. package/dist/services/code/step-08-gate.js +21 -6
  94. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  95. package/dist/services/config/config-safety.js +11 -9
  96. package/dist/services/context/auto-compact-types.d.ts +20 -2
  97. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  98. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  99. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  100. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  101. package/dist/services/final-review/pre-post-diff.js +10 -2
  102. package/dist/services/job/job-progress-store.js +18 -3
  103. package/dist/services/observability/jsonl-store.d.ts +19 -0
  104. package/dist/services/observability/jsonl-store.js +27 -2
  105. package/dist/services/observability/observability-service.d.ts +11 -4
  106. package/dist/services/observability/observability-service.js +16 -3
  107. package/dist/services/prd/handoff-service.js +43 -0
  108. package/dist/services/qa/qa-business-review-state.js +19 -5
  109. package/dist/services/sc/sc-service.d.ts +8 -0
  110. package/dist/services/sc/sc-service.js +8 -1
  111. package/dist/services/scan/api-diff-types.js +20 -2
  112. package/dist/services/security/safe-settings-path.js +19 -1
  113. package/dist/services/session/getSessionDir.d.ts +33 -0
  114. package/dist/services/session/getSessionDir.js +60 -0
  115. package/dist/services/skill/skill-search-service.d.ts +3 -3
  116. package/dist/services/slice/slice-review-state.js +19 -4
  117. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  118. package/dist/services/standards/loop-engineering-lint.js +6 -0
  119. package/dist/services/web/daemon-registry.js +27 -2
  120. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  121. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  122. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  123. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  124. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  125. package/dist/services/workspace/claude-settings-template.js +105 -83
  126. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  127. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  128. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  129. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  130. package/dist/services/workspace/workspace-service.js +11 -1
  131. package/dist/shared/fs-utils.d.ts +26 -0
  132. package/dist/shared/fs-utils.js +35 -0
  133. package/dist/shared/runtime-root.d.ts +73 -0
  134. package/dist/shared/runtime-root.js +77 -0
  135. package/package.json +9 -7
  136. package/scripts/copy-templates.mjs +0 -12
  137. package/scripts/install-skills.mjs +154 -53
  138. package/skills/bee/peaks-qa/SKILL.md +0 -1
  139. package/skills/bee/peaks-rd/SKILL.md +0 -1
  140. package/skills/peaks-code/SKILL.md +12 -10
  141. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  142. package/skills/peaks-code/references/runbook.md +3 -0
  143. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  144. package/skills/peaks-code/references/startup-sequence.md +2 -2
  145. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  146. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  147. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  148. package/dist/cli/commands/context-builder-commands.js +0 -85
  149. package/dist/services/hooks/write-gate.js +0 -111
  150. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  151. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  152. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  153. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  154. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  155. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  156. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -1,3 +1,16 @@
1
+ /**
2
+ * ⚠ DEAD CODE — zero importers in `src/`, `tests/`, `packages/`, `scripts/`.
3
+ * See the block comment on `src/services/adapter/adapter.ts`.
4
+ *
5
+ * `detectAndPick()` picks the first adapter whose `detect()` returns true.
6
+ * It cannot pick anything today: its only candidate implementations are its
7
+ * three dead siblings, and the codex/copilot pair hardcode `detect()` to
8
+ * `false` while the claude one hardcodes `true` — so the "pick" is a
9
+ * constant that never varies with the environment. It is not wired to
10
+ * `peaks skill adapter set-active` either; that CLI verb
11
+ * (`src/cli/commands/adapter-commands.ts`) echoes back the name it was given
12
+ * and reads no adapter file.
13
+ */
1
14
  import { Adapter } from "./adapter.js";
2
15
  type Detectable = Pick<Adapter, "name" | "detect">;
3
16
  export declare class AutoAdapter {
@@ -1,3 +1,15 @@
1
+ /**
2
+ * ⚠ DEAD CODE — not a live adapter. See the block comment on
3
+ * `src/services/adapter/adapter.ts` for the three same-named layers.
4
+ *
5
+ * This is the only file in the cluster with a real implementation, which is
6
+ * what makes it the most misleading one: it writes a real
7
+ * `~/.claude/skills/peaks-bee-<name>.peaks-generated/SKILL.md` scratch dir if
8
+ * you call it. Nothing calls it — zero importers in `src/`, `tests/`,
9
+ * `packages/` or `scripts/`. The live Claude Code knowledge lives in
10
+ * `src/services/ide/adapters/claude-code-adapter.ts` (IDE surface) and
11
+ * `src/services/runtime/vendors/claude-code.ts` (runtime compact).
12
+ */
1
13
  import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
2
14
  import { join } from "node:path";
3
15
  export class ClaudeAdapter {
@@ -1,3 +1,15 @@
1
+ /**
2
+ * ⚠ DEAD CODE — not the live `CodexAdapter`. See the block comment on
3
+ * `src/services/adapter/adapter.ts` for the three same-named layers, and
4
+ * `tests/unit/runtime/vendor-adapter-layer.test.ts` for the guard.
5
+ *
6
+ * The class below has zero importers anywhere in this repo. The live codex
7
+ * adapter is `src/services/runtime/vendors/codex.ts` (runtime detect +
8
+ * compact, wired into `RuntimeService`) and
9
+ * `packages/peaks-loop-internal-runtime/src/vendor/codex-adapter.ts` (the
10
+ * dispatched-sub-agent runtime). `detect()` here returns a hardcoded
11
+ * `false`; every other method throws `ADAPTER_NOT_IMPLEMENTED`.
12
+ */
1
13
  import { Adapter } from "./adapter.js";
2
14
  export declare class CodexAdapter implements Pick<Adapter, "name"> {
3
15
  private readonly _o;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * ⚠ DEAD CODE — not the live `CodexAdapter`. See the block comment on
3
+ * `src/services/adapter/adapter.ts` for the three same-named layers, and
4
+ * `tests/unit/runtime/vendor-adapter-layer.test.ts` for the guard.
5
+ *
6
+ * The class below has zero importers anywhere in this repo. The live codex
7
+ * adapter is `src/services/runtime/vendors/codex.ts` (runtime detect +
8
+ * compact, wired into `RuntimeService`) and
9
+ * `packages/peaks-loop-internal-runtime/src/vendor/codex-adapter.ts` (the
10
+ * dispatched-sub-agent runtime). `detect()` here returns a hardcoded
11
+ * `false`; every other method throws `ADAPTER_NOT_IMPLEMENTED`.
12
+ */
1
13
  import { ADAPTER_NOT_IMPLEMENTED } from "./adapter.js";
2
14
  export class CodexAdapter {
3
15
  _o;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * ⚠ DEAD CODE — not the live `CopilotAdapter`. See the block comment on
3
+ * `src/services/adapter/adapter.ts` for the three same-named layers, and
4
+ * `tests/unit/runtime/vendor-adapter-layer.test.ts` for the guard.
5
+ *
6
+ * The class below has zero importers anywhere in this repo. The live copilot
7
+ * adapter is `src/services/runtime/vendors/copilot.ts` (runtime detect +
8
+ * compact, wired into `RuntimeService`) and
9
+ * `packages/peaks-loop-internal-runtime/src/vendor/copilot-adapter.ts` (the
10
+ * dispatched-sub-agent runtime). `detect()` here returns a hardcoded
11
+ * `false`; every other method throws `ADAPTER_NOT_IMPLEMENTED`.
12
+ */
1
13
  import { Adapter } from "./adapter.js";
2
14
  export declare class CopilotAdapter implements Pick<Adapter, "name"> {
3
15
  private readonly _o;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * ⚠ DEAD CODE — not the live `CopilotAdapter`. See the block comment on
3
+ * `src/services/adapter/adapter.ts` for the three same-named layers, and
4
+ * `tests/unit/runtime/vendor-adapter-layer.test.ts` for the guard.
5
+ *
6
+ * The class below has zero importers anywhere in this repo. The live copilot
7
+ * adapter is `src/services/runtime/vendors/copilot.ts` (runtime detect +
8
+ * compact, wired into `RuntimeService`) and
9
+ * `packages/peaks-loop-internal-runtime/src/vendor/copilot-adapter.ts` (the
10
+ * dispatched-sub-agent runtime). `detect()` here returns a hardcoded
11
+ * `false`; every other method throws `ADAPTER_NOT_IMPLEMENTED`.
12
+ */
1
13
  import { ADAPTER_NOT_IMPLEMENTED } from "./adapter.js";
2
14
  export class CopilotAdapter {
3
15
  _o;
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'node:path';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
3
  import { pathExists } from 'peaks-loop-shared/fs';
4
4
  import { emitObservabilityEvent } from '../observability/observability-service.js';
5
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
5
6
  export const VALID_REQUEST_TYPES = [
6
7
  'feature',
7
8
  'bugfix',
@@ -426,6 +427,15 @@ export async function checkPrerequisites(options) {
426
427
  if (requirements.length === 0) {
427
428
  return { ok: true, missing: [], warnings: [] };
428
429
  }
430
+ // Repair R5. The session id is joined into BOTH roots below and then probed
431
+ // on disk. `transitionRequestArtifact` is the only caller and it passes
432
+ // `existing.sessionId`, which pre-R5 was the caller's raw `--session-id`: with
433
+ // `../../../PWNED-R34` the two joins resolved outside the project root and
434
+ // the prerequisite probes ran there. Guarded at the sink, not at the caller,
435
+ // for the same reason `requestArtifactRequestsDir` is.
436
+ if (options.sessionId !== undefined && isUnsafePathInput(options.sessionId)) {
437
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
438
+ }
429
439
  // Slice 006 simplifies the resolution to a 2-tier fallback. The
430
440
  // per-change-id scope (`.peaks/_runtime/<sessionId>/<role>/`) is gone — new
431
441
  // artifacts go to the session dir directly. The 2 tiers are:
@@ -12,6 +12,7 @@ import { ensureSession, getSessionIdCanonical } from '../session/session-manager
12
12
  import { getNextNumber, buildNumberedFilename, slugifyDescription } from '../../shared/incrementing-number.js';
13
13
  import { lintRequestArtifact } from './artifact-lint-service.js';
14
14
  import { isUnsafePathInput } from '../../shared/path-safety.js';
15
+ import { guardRuntimeSegment, runtimeRoot } from '../../shared/runtime-root.js';
15
16
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
16
17
  import { requireUserConfirmation } from '../mode/mode-enforcement.js';
17
18
  import { scanFileSize } from '../scan/file-size-scan.js';
@@ -46,6 +47,30 @@ function dateSlugFromIso(iso) {
46
47
  function defaultSessionId(iso) {
47
48
  return `${dateSlugFromIso(iso)}-session`;
48
49
  }
50
+ /**
51
+ * The single place a request-artifact directory is built, and therefore the
52
+ * single place the ids that reach it are guarded.
53
+ *
54
+ * Repair R5. This join used to be written at four sites; `createRequestArtifact`
55
+ * guarded its session id and the other three did not. The two ids are the whole
56
+ * of the invariant — `role` is a path segment too, and the closed-set check its
57
+ * callers perform is not visible from here.
58
+ *
59
+ * The guard belongs HERE and not at the entry points because the invariant is
60
+ * the resolved path, not the flag. `transitionRequestArtifact` is the measured
61
+ * cost: it performs no join of its own (it delegates the path to
62
+ * `showRequestArtifact`), so it had no guard and no join to hang one on.
63
+ * `--session-id ../../../PWNED-R34` resolved `.peaks/_runtime/../../../PWNED-R34`,
64
+ * wrote `state: blocked` to a file outside the project root, and only then threw
65
+ * — from `emitObservabilityEvent`'s own session-id check, i.e. after the write.
66
+ */
67
+ function requestArtifactRequestsDir(projectRoot, sessionId, role) {
68
+ // Slice 2026-09-15 (runtime-path-unrepresentable): the two ids are branded by
69
+ // `guardRuntimeSegment`, which performs the same `isUnsafePathInput` check
70
+ // this function used to spell inline. The join itself now *requires* the
71
+ // brand, so a caller that reaches this dir without a guard does not compile.
72
+ return runtimeRoot(projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment(role, 'role'), guardRuntimeSegment('requests', 'leaf'));
73
+ }
49
74
  export async function createRequestArtifact(options) {
50
75
  if (!VALID_ROLES.has(options.role)) {
51
76
  throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
@@ -95,7 +120,7 @@ export async function createRequestArtifact(options) {
95
120
  // `mkdir(..., { recursive: true })`.
96
121
  const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
97
122
  if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
98
- const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
123
+ const sessionDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
99
124
  if (!(await isDirectory(sessionDir))) {
100
125
  const canonicalSid = getSessionIdCanonical(options.projectRoot);
101
126
  const hint = canonicalSid !== null
@@ -105,7 +130,7 @@ export async function createRequestArtifact(options) {
105
130
  }
106
131
  }
107
132
  // Build numbered path under the session dir (canonical post-F3 home).
108
- const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
133
+ const requestsDir = requestArtifactRequestsDir(options.projectRoot, sessionId, options.role);
109
134
  // Check if a file with this requestId already exists (regardless of number prefix)
110
135
  if (await isDirectory(requestsDir)) {
111
136
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -133,7 +158,7 @@ export async function createRequestArtifact(options) {
133
158
  // Slice 2026-06-29-change-id-root-removal: scopeDir is the
134
159
  // session-axis dir (`.peaks/_runtime/<sid>/`). Pre-resolved here
135
160
  // so dry-run output reports the canonical scope location.
136
- const scopeDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
161
+ const scopeDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
137
162
  return {
138
163
  role: options.role,
139
164
  requestId: options.requestId,
@@ -150,7 +175,7 @@ export async function createRequestArtifact(options) {
150
175
  // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
151
176
  // The marker lives under the SESSION dir (canonical post-F3 home).
152
177
  if (options.role === 'qa') {
153
- const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
178
+ const qaDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment('qa', 'role'));
154
179
  const initiatedPath = join(qaDir, '.initiated');
155
180
  if (!existsSync(initiatedPath)) {
156
181
  await mkdir(qaDir, { recursive: true });
@@ -164,7 +189,7 @@ export async function createRequestArtifact(options) {
164
189
  path,
165
190
  content,
166
191
  applied: true,
167
- scopeDir: join(options.projectRoot, '.peaks', '_runtime', sessionId),
192
+ scopeDir: runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id')),
168
193
  ...(options.callerId !== undefined ? { callerId: options.callerId } : {})
169
194
  };
170
195
  }
@@ -202,31 +227,27 @@ function extractMetadata(markdown) {
202
227
  base.sessionId = sessionId;
203
228
  return base;
204
229
  }
205
- async function readSummary(projectRoot, sessionId, role, fileName) {
206
- const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
230
+ async function readSummary(dir, role, fileName, sessionId) {
231
+ const path = join(dir, fileName);
207
232
  const body = await readFile(path, 'utf8');
208
233
  const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
209
234
  // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
210
235
  // Only strip 3-digit zero-padded prefixes (our incrementing number format)
211
236
  const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
212
- // The `sessionId` parameter is the *scope* path fragment
213
- // (`_runtime/<sid>`); consumers expect the bare session id. Strip
214
- // the `_runtime/` prefix when recording the summary so downstream
215
- // calls (observability emit, prereq check, lint gate) see just the
216
- // session id. Pre-2.19.0 the field carried the scope verbatim, which
217
- // caused the observability metrics file to land at
218
- // `.peaks/_runtime/_runtime/<sid>/...` instead of the canonical
219
- // `.peaks/_runtime/<sid>/metrics/...`. `writerSessionId` falls back
220
- // to the parsed body session line (or the bare sid) — same intent.
221
- const bareSessionId = sessionId.replace(/^_runtime[\\/]/, '');
237
+ // Repair R5: this used to take the *scope* path fragment (`_runtime/<sid>`)
238
+ // and re-join it onto the project root, then strip the prefix back off to
239
+ // recover the bare session id. The round-trip was the escape's carrier — an
240
+ // unsafe id rode it into `path` unguarded. The directory now arrives already
241
+ // resolved and already guarded (`requestArtifactRequestsDir`), and the bare
242
+ // session id arrives as itself, so neither is re-derived here.
222
243
  const summary = {
223
244
  role,
224
- sessionId: bareSessionId,
245
+ sessionId,
225
246
  requestId,
226
247
  path,
227
248
  state,
228
249
  requestType,
229
- writerSessionId: bodySessionId ?? bareSessionId
250
+ writerSessionId: bodySessionId ?? sessionId
230
251
  };
231
252
  if (createdAt !== undefined) {
232
253
  summary.createdAt = createdAt;
@@ -254,26 +275,28 @@ export async function listRequestArtifacts(options) {
254
275
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout —
255
276
  // the CLI guarantees no such dirs are created. See
256
277
  // `.peaks/memory/2026-06-21-peaks-request-session-id-leaks-into-change-id.md`.
278
+ // Repair R5: `scopes` holds bare session ids. It used to hold the joined
279
+ // fragment `_runtime/<sid>` so that `readSummary` could re-join it to the
280
+ // project root; the directory is built once, below, by the guard.
257
281
  const scopes = [];
258
282
  if (options.sessionId !== undefined) {
259
- scopes.push(join('_runtime', options.sessionId));
283
+ scopes.push(options.sessionId);
260
284
  }
261
285
  else {
262
- const runtimeRoot = join(peaksRoot, '_runtime');
263
- if (await isDirectory(runtimeRoot)) {
264
- for (const sid of await listDirectories(runtimeRoot)) {
265
- scopes.push(join('_runtime', sid));
266
- }
286
+ // Read-only enumeration of the root itself, so `dir()` and not `join()`.
287
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
288
+ if (await isDirectory(runtimeDir)) {
289
+ scopes.push(...(await listDirectories(runtimeDir)));
267
290
  }
268
291
  }
269
292
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
270
293
  const summaries = [];
271
294
  for (const scope of scopes) {
272
295
  for (const role of roles) {
273
- const dir = join(peaksRoot, scope, role, 'requests');
296
+ const dir = requestArtifactRequestsDir(options.projectRoot, scope, role);
274
297
  const fileNames = await listMarkdownFiles(dir);
275
298
  for (const fileName of fileNames) {
276
- summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
299
+ summaries.push(await readSummary(dir, role, fileName, scope));
277
300
  }
278
301
  }
279
302
  }
@@ -311,24 +334,22 @@ export async function showRequestArtifact(options) {
311
334
  // `.peaks/_runtime/<sid>/<role>/requests/` legacy home is no longer
312
335
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout.
313
336
  if (options.sessionId !== undefined) {
314
- const dir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
315
- const scope = join('_runtime', options.sessionId);
337
+ const dir = requestArtifactRequestsDir(options.projectRoot, options.sessionId, options.role);
316
338
  const found = await findFileInDir(dir);
317
339
  if (found === null) {
318
340
  return null;
319
341
  }
320
- return await readRequestArtifact(options.projectRoot, scope, options.role, found);
342
+ return await readRequestArtifact(dir, options.role, found, options.sessionId);
321
343
  }
322
- const peaksRoot = join(options.projectRoot, '.peaks');
323
- const runtimeRoot = join(peaksRoot, '_runtime');
324
- if (!(await isDirectory(runtimeRoot))) {
344
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
345
+ if (!(await isDirectory(runtimeDir))) {
325
346
  return null;
326
347
  }
327
- for (const sid of await listDirectories(runtimeRoot)) {
328
- const dir = join(runtimeRoot, sid, options.role, 'requests');
348
+ for (const sid of await listDirectories(runtimeDir)) {
349
+ const dir = requestArtifactRequestsDir(options.projectRoot, sid, options.role);
329
350
  const found = await findFileInDir(dir);
330
351
  if (found !== null) {
331
- return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
352
+ return await readRequestArtifact(dir, options.role, found, sid);
332
353
  }
333
354
  }
334
355
  return null;
@@ -336,8 +357,8 @@ export async function showRequestArtifact(options) {
336
357
  /** Read the summary + content for a found request file; treat a read
337
358
  * error on the content as "not found" so the caller can fall through
338
359
  * to the next candidate (the on-disk file may be partially written). */
339
- async function readRequestArtifact(projectRoot, scope, role, found) {
340
- const summary = await readSummary(projectRoot, scope, role, found.fileName);
360
+ async function readRequestArtifact(dir, role, found, sessionId) {
361
+ const summary = await readSummary(dir, role, found.fileName, sessionId);
341
362
  try {
342
363
  const content = await readFile(found.path, 'utf8');
343
364
  return { ...summary, content };
@@ -2,23 +2,41 @@
2
2
  * Backing detector — classifies each red line as `cli-backed`, `partial`,
3
3
  * or `prose-only`. The classifier already sets the backing for catalog hits
4
4
  * (cli-backed when an enforcer file path is present). This module exists to
5
- * handle the post-classification nuance: heuristics for the "partial" tier
6
- * (a gate exists but the LLM can bypass it) and verification that the
7
- * enforcer file actually exists on disk.
5
+ * handle the post-classification nuances: heuristics for the "partial" tier
6
+ * (a gate exists but the LLM can bypass it), verification that the enforcer
7
+ * file exists on disk, and — since A9 of the 2026-09-15 diagnosis —
8
+ * verification that the enforcer is actually reachable from a call site.
9
+ *
10
+ * `cli-backed` means "a CLI surface runs this rule". A file that exists but
11
+ * that nothing imports runs nothing, so it is `prose-only` with the same
12
+ * weight as an unwritten rule. Callers pass the live set from
13
+ * `enforcer-liveness.ts`; `null` means "liveness undecidable for this
14
+ * project" (no `src/` tree), in which case the existing file is trusted.
8
15
  */
9
16
  import type { RedLineEntry } from './types.js';
17
+ /**
18
+ * `liveEnforcers` is the set of enforcerRefs with a call site (see
19
+ * `enforcer-liveness.ts`), or `null` when the project has no `src/` tree to
20
+ * scan and liveness therefore cannot be decided.
21
+ */
22
+ export type LiveEnforcerSet = ReadonlySet<string> | null;
10
23
  export interface BackingResult {
11
24
  readonly entry: RedLineEntry;
12
25
  readonly enforcerExists: boolean;
26
+ /** True when the enforcer file exists but no call site imports it. */
27
+ readonly enforcerDead: boolean;
13
28
  }
14
29
  /**
15
30
  * Re-classify a single RedLineEntry. Returns a new entry with the
16
- * `backing` field updated and `enforcerRef` possibly nulled if the
17
- * referenced file does not exist on disk.
31
+ * `backing` field updated; `enforcerRef` is preserved even when the
32
+ * backing is downgraded, because the dead reference is the triage
33
+ * information the report needs.
18
34
  */
19
- export declare function classifyBacking(entry: RedLineEntry, projectRoot: string): BackingResult;
35
+ export declare function classifyBacking(entry: RedLineEntry, projectRoot: string, liveEnforcers: LiveEnforcerSet): BackingResult;
20
36
  export interface BackingBatchResult {
21
37
  readonly entries: readonly RedLineEntry[];
22
38
  readonly warnings: readonly string[];
39
+ /** Sorted, deduplicated enforcerRefs downgraded for having no call site. */
40
+ readonly deadEnforcers: readonly string[];
23
41
  }
24
- export declare function classifyBackingBatch(entries: readonly RedLineEntry[], projectRoot: string): BackingBatchResult;
42
+ export declare function classifyBackingBatch(entries: readonly RedLineEntry[], projectRoot: string, liveEnforcers: LiveEnforcerSet): BackingBatchResult;
@@ -2,9 +2,16 @@
2
2
  * Backing detector — classifies each red line as `cli-backed`, `partial`,
3
3
  * or `prose-only`. The classifier already sets the backing for catalog hits
4
4
  * (cli-backed when an enforcer file path is present). This module exists to
5
- * handle the post-classification nuance: heuristics for the "partial" tier
6
- * (a gate exists but the LLM can bypass it) and verification that the
7
- * enforcer file actually exists on disk.
5
+ * handle the post-classification nuances: heuristics for the "partial" tier
6
+ * (a gate exists but the LLM can bypass it), verification that the enforcer
7
+ * file exists on disk, and — since A9 of the 2026-09-15 diagnosis —
8
+ * verification that the enforcer is actually reachable from a call site.
9
+ *
10
+ * `cli-backed` means "a CLI surface runs this rule". A file that exists but
11
+ * that nothing imports runs nothing, so it is `prose-only` with the same
12
+ * weight as an unwritten rule. Callers pass the live set from
13
+ * `enforcer-liveness.ts`; `null` means "liveness undecidable for this
14
+ * project" (no `src/` tree), in which case the existing file is trusted.
8
15
  */
9
16
  import { existsSync } from 'node:fs';
10
17
  import { resolve } from 'node:path';
@@ -23,37 +30,46 @@ function detectPartial(context) {
23
30
  }
24
31
  /**
25
32
  * Re-classify a single RedLineEntry. Returns a new entry with the
26
- * `backing` field updated and `enforcerRef` possibly nulled if the
27
- * referenced file does not exist on disk.
33
+ * `backing` field updated; `enforcerRef` is preserved even when the
34
+ * backing is downgraded, because the dead reference is the triage
35
+ * information the report needs.
28
36
  */
29
- export function classifyBacking(entry, projectRoot) {
37
+ export function classifyBacking(entry, projectRoot, liveEnforcers) {
38
+ const enforcerPath = entry.enforcerRef === null ? null : resolve(projectRoot, entry.enforcerRef);
39
+ const exists = enforcerPath !== null && existsSync(enforcerPath);
40
+ const dead = entry.enforcerRef !== null && exists && liveEnforcers !== null && !liveEnforcers.has(entry.enforcerRef);
30
41
  if (detectPartial(entry.source.context)) {
31
42
  return {
32
43
  entry: { ...entry, backing: 'partial' },
33
- enforcerExists: entry.enforcerRef !== null && existsSync(resolve(projectRoot, entry.enforcerRef)),
44
+ enforcerExists: exists,
45
+ enforcerDead: dead,
34
46
  };
35
47
  }
36
48
  if (entry.enforcerRef === null) {
37
- return { entry, enforcerExists: false };
49
+ return { entry, enforcerExists: false, enforcerDead: false };
38
50
  }
39
- const enforcerPath = resolve(projectRoot, entry.enforcerRef);
40
- const exists = existsSync(enforcerPath);
51
+ const backed = exists && !dead;
41
52
  return {
42
- entry: { ...entry, backing: exists ? 'cli-backed' : 'prose-only' },
53
+ entry: { ...entry, backing: backed ? 'cli-backed' : 'prose-only' },
43
54
  enforcerExists: exists,
55
+ enforcerDead: dead,
44
56
  };
45
57
  }
46
- export function classifyBackingBatch(entries, projectRoot) {
58
+ export function classifyBackingBatch(entries, projectRoot, liveEnforcers) {
47
59
  const updated = [];
48
60
  const warnings = [];
61
+ const dead = new Set();
49
62
  for (const entry of entries) {
50
- const { entry: reclassified, enforcerExists } = classifyBacking(entry, projectRoot);
51
- updated.push(reclassified);
52
- if (reclassified.backing === 'cli-backed' && !enforcerExists) {
63
+ const result = classifyBacking(entry, projectRoot, liveEnforcers);
64
+ updated.push(result.entry);
65
+ if (result.enforcerDead && result.entry.enforcerRef !== null) {
66
+ dead.add(result.entry.enforcerRef);
67
+ }
68
+ if (result.entry.backing === 'cli-backed' && !result.enforcerExists) {
53
69
  // Defensive: should not happen because classifyBacking downgrades to
54
70
  // prose-only, but keep the assertion in case of future drift.
55
- warnings.push(`enforcer ref "${reclassified.enforcerRef}" missing on disk for ${reclassified.id}`);
71
+ warnings.push(`enforcer ref "${result.entry.enforcerRef}" missing on disk for ${result.entry.id}`);
56
72
  }
57
73
  }
58
- return { entries: updated, warnings };
74
+ return { entries: updated, warnings, deadEnforcers: [...dead].sort() };
59
75
  }
@@ -0,0 +1,12 @@
1
+ export interface LiveEnforcerScan {
2
+ /** Refs proven imported from a call site. Empty when `unknown` is true. */
3
+ readonly live: ReadonlySet<string>;
4
+ /** True when the project has no `src/` tree, so liveness is undecidable. */
5
+ readonly unknown: boolean;
6
+ readonly warnings: readonly string[];
7
+ }
8
+ /**
9
+ * Scan `projectRoot/src` for relative imports that name one of `enforcerRefs`.
10
+ * Returns the subset of refs with a call site outside the enforcers directory.
11
+ */
12
+ export declare function computeLiveEnforcers(projectRoot: string, enforcerRefs: readonly string[]): LiveEnforcerScan;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Enforcer liveness — A9 of `docs/diagnosis-2026-09-15-peaks-loop-state.md`.
3
+ *
4
+ * `cli-backed` used to mean "the `enforcerRef` path exists on disk"
5
+ * (`backing-detector.ts`). It did not mean "the enforcer runs". Ten catalog
6
+ * enforcerRefs name a file no module imports — `lint-rd-handoff-coverage.ts`
7
+ * among them — so the audit reported 108/152 red lines as CLI-enforced while
8
+ * a quarter of those had no caller at all.
9
+ *
10
+ * This module answers the narrower, checkable question: is the enforcer
11
+ * module imported by some file OUTSIDE the enforcers directory? A relative
12
+ * import is cheap proof that a call site exists — production code cannot
13
+ * invoke the module without one.
14
+ *
15
+ * Deliberate limits, stated rather than hidden:
16
+ * - An import is not a call. A module can be imported and never invoked.
17
+ * This check is necessary, not sufficient: it eliminates the "no caller
18
+ * exists at all" class and nothing beyond it.
19
+ * - Imports written inside `src/…/audit/enforcers/` do not count. A dead
20
+ * enforcer that imports another dead enforcer must not resurrect it.
21
+ * - A project with no `src/` tree yields `unknown: true`, never an empty
22
+ * live-set: peaks-loop audits consumer projects, and "cannot tell" must
23
+ * not read as "dead".
24
+ */
25
+ import { readdirSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, relative, resolve } from 'node:path';
27
+ const SOURCE_FILE = /\.(ts|tsx|mts|cts)$/;
28
+ const ENFORCER_DIR_FRAGMENT = '/services/audit/enforcers/';
29
+ /** Matches `from './x.js'`, `import('./x.js')` and `export … from './x.js'`. */
30
+ const RELATIVE_JS_IMPORT = /(?:from|import)\s*\(?\s*['"](\.\.?\/[^'"]+\.js)['"]/g;
31
+ function toPosix(value) {
32
+ return value.split('\\').join('/');
33
+ }
34
+ function* walkSourceFiles(root, warnings) {
35
+ const stack = [root];
36
+ while (stack.length > 0) {
37
+ const dir = stack.pop();
38
+ if (dir === undefined)
39
+ continue;
40
+ let dirents;
41
+ try {
42
+ dirents = readdirSync(dir, { withFileTypes: true });
43
+ }
44
+ catch (error) {
45
+ warnings.push(`enforcer-liveness: cannot read ${toPosix(dir)} (${String(error)})`);
46
+ continue;
47
+ }
48
+ for (const dirent of dirents) {
49
+ const full = join(dir, dirent.name);
50
+ if (dirent.isDirectory()) {
51
+ if (dirent.name === 'node_modules' || dirent.name === 'dist' || dirent.name.startsWith('.'))
52
+ continue;
53
+ stack.push(full);
54
+ }
55
+ else if (dirent.isFile() && SOURCE_FILE.test(dirent.name)) {
56
+ yield full;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Scan `projectRoot/src` for relative imports that name one of `enforcerRefs`.
63
+ * Returns the subset of refs with a call site outside the enforcers directory.
64
+ */
65
+ export function computeLiveEnforcers(projectRoot, enforcerRefs) {
66
+ const srcRoot = join(projectRoot, 'src');
67
+ const warnings = [];
68
+ const live = new Set();
69
+ const wanted = new Set(enforcerRefs);
70
+ let sawAnySource = false;
71
+ for (const absFile of walkSourceFiles(srcRoot, warnings)) {
72
+ sawAnySource = true;
73
+ const relFile = toPosix(relative(projectRoot, absFile));
74
+ // An import written inside the enforcers directory proves nothing.
75
+ if (relFile.includes(ENFORCER_DIR_FRAGMENT))
76
+ continue;
77
+ let source;
78
+ try {
79
+ source = readFileSync(absFile, 'utf8');
80
+ }
81
+ catch (error) {
82
+ warnings.push(`enforcer-liveness: cannot read ${relFile} (${String(error)})`);
83
+ continue;
84
+ }
85
+ RELATIVE_JS_IMPORT.lastIndex = 0;
86
+ let match = RELATIVE_JS_IMPORT.exec(source);
87
+ while (match !== null) {
88
+ const specifier = match[1] ?? '';
89
+ const resolved = toPosix(relative(projectRoot, resolve(dirname(absFile), specifier)));
90
+ const asTs = resolved.replace(/\.js$/, '.ts');
91
+ if (wanted.has(asTs))
92
+ live.add(asTs);
93
+ match = RELATIVE_JS_IMPORT.exec(source);
94
+ }
95
+ }
96
+ if (!sawAnySource) {
97
+ return { live, unknown: true, warnings };
98
+ }
99
+ return { live, unknown: false, warnings };
100
+ }
@@ -64,7 +64,20 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
64
64
  // single skill per resolution. When the lease dir is empty (e.g.
65
65
  // ad-hoc / pre-migration projects) we fall through to the legacy
66
66
  // walk below.
67
- const sessionDir = getSessionDir(projectRoot, sessionId);
67
+ // `getSessionDir` refuses an unsafe session id by throwing (slice
68
+ // 2026-09-14-getsessiondir-guard). This function's contract is the
69
+ // resolution order's "graceful degradation — never throws", so an unsafe
70
+ // id degrades to the same `source: 'none'` shape an absent session dir
71
+ // produces, exactly as it did before that guard existed. Without this,
72
+ // the throw escapes to the nearest caller `catch` — for `hook handle`
73
+ // that catch is a fail-open that skips the SOP gate.
74
+ let sessionDir;
75
+ try {
76
+ sessionDir = getSessionDir(projectRoot, sessionId);
77
+ }
78
+ catch {
79
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
80
+ }
68
81
  if (!existsSync(sessionDir)) {
69
82
  return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
70
83
  }