peaks-loop 4.0.47 → 4.0.48

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 (104) hide show
  1. package/CHANGELOG.md +24 -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 +112 -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/hooks-commands.js +4 -4
  15. package/dist/cli/commands/job-commands.js +8 -0
  16. package/dist/cli/commands/loop-eval-commands.js +15 -0
  17. package/dist/cli/commands/perf-audit-commands.js +2 -0
  18. package/dist/cli/commands/playwright-commands.js +12 -0
  19. package/dist/cli/commands/prd-commands.js +1 -1
  20. package/dist/cli/commands/qa-commands.js +22 -0
  21. package/dist/cli/commands/request-commands.js +8 -0
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/security-audit-commands.js +2 -0
  24. package/dist/cli/commands/slice-integrate-commands.js +5 -0
  25. package/dist/cli/commands/statusline-commands.js +44 -4
  26. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  27. package/dist/cli/commands/sub-agent/detached.js +47 -22
  28. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  29. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  30. package/dist/cli/commands/workflow-commands.js +1 -1
  31. package/dist/cli/index.js +5 -45
  32. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  33. package/dist/services/artifacts/artifact-prerequisites.js +130 -65
  34. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  35. package/dist/services/artifacts/request-artifact-service.js +18 -8
  36. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  37. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  38. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  39. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  40. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  41. package/dist/services/audit-independent/security-audit-service.js +28 -6
  42. package/dist/services/code/auto-compact-lifecycle.d.ts +119 -0
  43. package/dist/services/code/auto-compact-lifecycle.js +169 -0
  44. package/dist/services/code/auto-compact-orchestrator.js +13 -2
  45. package/dist/services/code/compact-event-settle.d.ts +122 -0
  46. package/dist/services/code/compact-event-settle.js +219 -0
  47. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  48. package/dist/services/config/config-restore.d.ts +12 -1
  49. package/dist/services/config/config-restore.js +35 -4
  50. package/dist/services/config/config-rollback.js +6 -1
  51. package/dist/services/context/harness-context-witness.d.ts +310 -0
  52. package/dist/services/context/harness-context-witness.js +606 -0
  53. package/dist/services/evidence/evidence-generator.js +86 -49
  54. package/dist/services/final-review/final-review-service.d.ts +9 -0
  55. package/dist/services/final-review/final-review-service.js +36 -12
  56. package/dist/services/ide/ide-registry.d.ts +19 -0
  57. package/dist/services/ide/ide-registry.js +21 -0
  58. package/dist/services/job/job-state-store.js +7 -0
  59. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  60. package/dist/services/prd/handoff-auto-regen.js +31 -27
  61. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  62. package/dist/services/prd/handoff-frontmatter.js +75 -0
  63. package/dist/services/prd/handoff-service.d.ts +41 -2
  64. package/dist/services/prd/handoff-service.js +81 -8
  65. package/dist/services/prd/handoff-types.d.ts +3 -2
  66. package/dist/services/prd/handoff-types.js +3 -2
  67. package/dist/services/qa/qa-business-review-state.js +9 -0
  68. package/dist/services/scan/karpathy-service.js +2 -2
  69. package/dist/services/session/session-checkpoint-service.js +8 -0
  70. package/dist/services/skill/resume-detector.js +29 -11
  71. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  72. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  73. package/dist/services/skills/hooks-settings-service.js +14 -4
  74. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  75. package/dist/services/skills/session-start-hook-constants.js +45 -0
  76. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  77. package/dist/services/slice/slice-check-service.js +29 -11
  78. package/dist/services/slice/slice-review-state.js +8 -0
  79. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  80. package/dist/services/workflow/pipeline-verify-gate-support.js +212 -93
  81. package/dist/services/workflow/pipeline-verify-service.js +24 -23
  82. package/dist/services/workflow/pipeline-verify-types.d.ts +10 -3
  83. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  84. package/dist/services/workspace/claude-settings-template.js +98 -20
  85. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  86. package/package.json +6 -6
  87. package/skills/bee/peaks-prd/SKILL.md +7 -5
  88. package/skills/bee/peaks-qa/SKILL.md +5 -5
  89. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  90. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  91. package/skills/bee/peaks-rd/SKILL.md +8 -6
  92. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  93. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  94. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  95. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  96. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  97. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  98. package/skills/peaks-code/SKILL.md +1 -1
  99. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  100. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  101. package/skills/peaks-code/references/resume-detection.md +13 -7
  102. package/skills/peaks-code/references/runbook.md +3 -2
  103. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  104. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -11,6 +11,7 @@ import { ensureSession, getSessionIdCanonical } from '../session/session-manager
11
11
  // live at `shared/path-safety.ts` if this module ever needs them.
12
12
  import { getNextNumber, buildNumberedFilename, slugifyDescription } from '../../shared/incrementing-number.js';
13
13
  import { lintRequestArtifact } from './artifact-lint-service.js';
14
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
14
15
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
15
16
  import { requireUserConfirmation } from '../mode/mode-enforcement.js';
16
17
  import { scanFileSize } from '../scan/file-size-scan.js';
@@ -27,7 +28,14 @@ export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
27
28
  // the local namespace under `isolatedModules`).
28
29
  import { renderTemplate } from './artifact-templates.js';
29
30
  export { formatHandoffPath, formatCommitBoundaryPath, formatSkillUsageLessonsPath } from './artifact-templates.js';
30
- const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
31
+ /**
32
+ * F-1 (slice 025 security): reject rids that contain path separators, null
33
+ * bytes, or traversal sequences. A request id is a single path segment, so
34
+ * anything that builds a filename from one must test it against this first —
35
+ * it is exported so those call sites reuse it instead of re-declaring a copy
36
+ * that can drift.
37
+ */
38
+ export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
31
39
  const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
32
40
  function defaultClock() {
33
41
  return new Date().toISOString();
@@ -60,6 +68,13 @@ export async function createRequestArtifact(options) {
60
68
  // in the artifact body's frontmatter (under `- change-id:`) for
61
69
  // human navigation; it is no longer a filesystem path key.
62
70
  const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
71
+ // Sid axis. The rid axis is guarded three times above (`REQUEST_ID_PATTERN`
72
+ // at :110, and again in the numbered-filename path); the session id was
73
+ // never checked, so `--session-id ../../x` wrote the artifact outside the
74
+ // project root under an `ok: true` envelope.
75
+ if (isUnsafePathInput(sessionId)) {
76
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
77
+ }
63
78
  // Slice 2026-06-29-change-id-root-removal: the `current-change`
64
79
  // binding file is gone. Resolution order for the change-id (file
65
80
  // body metadata) is now:
@@ -154,17 +169,12 @@ export async function createRequestArtifact(options) {
154
169
  };
155
170
  }
156
171
  function extractMetadata(markdown) {
157
- let state = 'unknown';
172
+ const state = readArtifactState(markdown) ?? 'unknown';
158
173
  let createdAt;
159
174
  let requestType = DEFAULT_REQUEST_TYPE;
160
175
  let sessionId;
161
176
  for (const rawLine of markdown.split(/\r?\n/)) {
162
177
  const line = rawLine.trim();
163
- const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(line);
164
- if (stateMatch !== null && stateMatch[1] !== undefined) {
165
- state = stateMatch[1];
166
- continue;
167
- }
168
178
  const createdMatch = /^-\s*created:\s*(.+?)\s*$/.exec(line);
169
179
  if (createdMatch !== null && createdMatch[1] !== undefined) {
170
180
  createdAt = createdMatch[1];
@@ -343,7 +353,7 @@ async function readRequestArtifact(projectRoot, scope, role, found) {
343
353
  // the sibling `request-artifact-state-helpers.ts` module — see
344
354
  // v2.18.3 file-split for the rationale. Function signatures and
345
355
  // behaviour are unchanged (verbatim move).
346
- import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock, } from './request-artifact-state-helpers.js';
356
+ import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, readArtifactState, TypeSanityViolationError, updateStatusBlock, } from './request-artifact-state-helpers.js';
347
357
  export { allowedStatesForRole, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock } from './request-artifact-state-helpers.js';
348
358
  export async function transitionRequestArtifact(options) {
349
359
  if (!VALID_ROLES.has(options.role)) {
@@ -45,6 +45,63 @@ export declare class FileSizeViolationError extends Error {
45
45
  lines: number;
46
46
  }>, threshold: number);
47
47
  }
48
+ export interface ArtifactStateLocation {
49
+ /** Index of the authoritative `- state:` line, or -1 when the document has none. */
50
+ stateLineIndex: number;
51
+ /** The authoritative state, or null when `stateLineIndex` is -1. */
52
+ state: string | null;
53
+ }
54
+ /**
55
+ * The ONE rule for "which `state:` line is this artifact's state": the LAST
56
+ * one. A request artifact is an append-only log — each QA round appends a
57
+ * section ending in its own `## Status`, and `request transition` rewrites the
58
+ * newest state line in place — so the last line is the current round and the
59
+ * earlier ones are history.
60
+ *
61
+ * Every reader (`verify-pipeline`, `request show`, the resume detector) and the
62
+ * writer (`updateStatusBlock`) MUST go through this. The 2026-09-14 defect was
63
+ * three readers disagreeing about one file: `verify-pipeline` and the resume
64
+ * detector took the first match, `request show` took the last, so an artifact
65
+ * appended to more than once read as its first round to the checker and the
66
+ * resume detector while reading as its last round to the viewer and the writer.
67
+ *
68
+ * Scoping the search to the last `## Status` block was evaluated as an
69
+ * alternative and rejected — as a SECOND locator, not as a broken rule. It is
70
+ * well defined on the specimen that motivated this slice (four blocks) and
71
+ * returns `verdict-issued`, the right answer; and a trailing appended
72
+ * `- state:` line does not defeat it the way it defeats this rule — on that
73
+ * input the two rules disagree, and the one the block rule then disagrees with
74
+ * is the writer. `updateStatusBlock` rewrites the last `- state:` line wherever
75
+ * it sits and never moves it into the newest block, so a block-scoped reader
76
+ * parts company with the writer as soon as those two positions differ: the
77
+ * writer writes the appended line while the block reader keeps reporting the
78
+ * block's own line. That is this slice's reader-vs-writer divergence on a new
79
+ * axis. Sharing the writer's locator makes the agreement structural, not
80
+ * accidental.
81
+ *
82
+ * Known boundary of this rule, pinned in
83
+ * `request-artifact-state-authority.test.ts` rather than hidden: a process that
84
+ * appends a bare `- state:` line takes over the field — consistent with the
85
+ * writer being its only sanctioned producer.
86
+ *
87
+ * Two narrowings keep that boundary to lines the writer could have produced.
88
+ * A line inside a fenced code region is skipped, and the line must start at
89
+ * column 0. Neither is defensive decoration: a document that *describes* the
90
+ * state machine quotes `- state: qa-block` inside a fence, and this job's own
91
+ * `qa/requests/*.md` artifacts do exactly that — under the unfenced rule the
92
+ * quoted example was an input to the transition checker, and it survived only
93
+ * because the quoted copies happened not to be last. Both narrowings were
94
+ * measured against every `*.md` under `.peaks/` (833 files) and change no
95
+ * artifact's answer, and the writer already satisfies both by construction
96
+ * (`updateStatusBlock` writes `- state: <state>` at column 0), so reader and
97
+ * writer stay the same locator.
98
+ *
99
+ * Fuller record: the slice-3 section of this session's `rd/tech-doc.md` and
100
+ * the repair-round section of `rd/repair2-meta-integrity-fixes.md`.
101
+ */
102
+ export declare function locateArtifactState(lines: ReadonlyArray<string>): ArtifactStateLocation;
103
+ /** `locateArtifactState` over a whole document. Null when there is no `- state:` line. */
104
+ export declare function readArtifactState(markdown: string): string | null;
48
105
  export declare function updateStatusBlock(markdown: string, newState: RequestArtifactState, timestamp: string, reason?: string): {
49
106
  updated: string;
50
107
  previousState: string;
@@ -75,20 +75,101 @@ export class FileSizeViolationError extends Error {
75
75
  this.threshold = threshold;
76
76
  }
77
77
  }
78
- export function updateStatusBlock(markdown, newState, timestamp, reason) {
79
- const lines = markdown.split(/\r?\n/);
80
- let previousState = 'unknown';
78
+ /**
79
+ * The `- state:` line the artifact templates and `request transition` write.
80
+ * Anchored on both ends so a prose mention (`state: qa-block`, without the
81
+ * leading dash) is not read as the field, and anchored at column 0 because
82
+ * every writer of this field (`updateStatusBlock`, `request init`) emits it
83
+ * as a top-level line — an indented match is a nested list item, not the
84
+ * field. `locateArtifactState` applies it only outside fenced code regions.
85
+ */
86
+ const STATE_LINE_RE = /^-\s*state:\s*(.+?)\s*$/;
87
+ /** A fenced-code delimiter line: three or more backticks or tildes. */
88
+ const FENCE_LINE_RE = /^(`{3,}|~{3,})/;
89
+ /**
90
+ * The ONE rule for "which `state:` line is this artifact's state": the LAST
91
+ * one. A request artifact is an append-only log — each QA round appends a
92
+ * section ending in its own `## Status`, and `request transition` rewrites the
93
+ * newest state line in place — so the last line is the current round and the
94
+ * earlier ones are history.
95
+ *
96
+ * Every reader (`verify-pipeline`, `request show`, the resume detector) and the
97
+ * writer (`updateStatusBlock`) MUST go through this. The 2026-09-14 defect was
98
+ * three readers disagreeing about one file: `verify-pipeline` and the resume
99
+ * detector took the first match, `request show` took the last, so an artifact
100
+ * appended to more than once read as its first round to the checker and the
101
+ * resume detector while reading as its last round to the viewer and the writer.
102
+ *
103
+ * Scoping the search to the last `## Status` block was evaluated as an
104
+ * alternative and rejected — as a SECOND locator, not as a broken rule. It is
105
+ * well defined on the specimen that motivated this slice (four blocks) and
106
+ * returns `verdict-issued`, the right answer; and a trailing appended
107
+ * `- state:` line does not defeat it the way it defeats this rule — on that
108
+ * input the two rules disagree, and the one the block rule then disagrees with
109
+ * is the writer. `updateStatusBlock` rewrites the last `- state:` line wherever
110
+ * it sits and never moves it into the newest block, so a block-scoped reader
111
+ * parts company with the writer as soon as those two positions differ: the
112
+ * writer writes the appended line while the block reader keeps reporting the
113
+ * block's own line. That is this slice's reader-vs-writer divergence on a new
114
+ * axis. Sharing the writer's locator makes the agreement structural, not
115
+ * accidental.
116
+ *
117
+ * Known boundary of this rule, pinned in
118
+ * `request-artifact-state-authority.test.ts` rather than hidden: a process that
119
+ * appends a bare `- state:` line takes over the field — consistent with the
120
+ * writer being its only sanctioned producer.
121
+ *
122
+ * Two narrowings keep that boundary to lines the writer could have produced.
123
+ * A line inside a fenced code region is skipped, and the line must start at
124
+ * column 0. Neither is defensive decoration: a document that *describes* the
125
+ * state machine quotes `- state: qa-block` inside a fence, and this job's own
126
+ * `qa/requests/*.md` artifacts do exactly that — under the unfenced rule the
127
+ * quoted example was an input to the transition checker, and it survived only
128
+ * because the quoted copies happened not to be last. Both narrowings were
129
+ * measured against every `*.md` under `.peaks/` (833 files) and change no
130
+ * artifact's answer, and the writer already satisfies both by construction
131
+ * (`updateStatusBlock` writes `- state: <state>` at column 0), so reader and
132
+ * writer stay the same locator.
133
+ *
134
+ * Fuller record: the slice-3 section of this session's `rd/tech-doc.md` and
135
+ * the repair-round section of `rd/repair2-meta-integrity-fixes.md`.
136
+ */
137
+ export function locateArtifactState(lines) {
81
138
  let stateLineIndex = -1;
82
- let lastUpdateLineIndex = -1;
139
+ let state = null;
140
+ let fence = null;
83
141
  for (const [index, raw] of lines.entries()) {
84
- const trimmed = raw.trim();
85
- const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(trimmed);
86
- if (stateMatch !== null && stateMatch[1] !== undefined) {
87
- previousState = stateMatch[1];
88
- stateLineIndex = index;
142
+ const fenceMatch = FENCE_LINE_RE.exec(raw.trim());
143
+ if (fence === null) {
144
+ if (fenceMatch !== null) {
145
+ fence = fenceMatch[1][0];
146
+ continue;
147
+ }
148
+ }
149
+ else {
150
+ if (fenceMatch !== null && fenceMatch[1][0] === fence)
151
+ fence = null;
89
152
  continue;
90
153
  }
91
- if (/^-\s*last update:\s*/.test(trimmed)) {
154
+ const match = STATE_LINE_RE.exec(raw);
155
+ if (match?.[1] !== undefined) {
156
+ stateLineIndex = index;
157
+ state = match[1];
158
+ }
159
+ }
160
+ return { stateLineIndex, state };
161
+ }
162
+ /** `locateArtifactState` over a whole document. Null when there is no `- state:` line. */
163
+ export function readArtifactState(markdown) {
164
+ return locateArtifactState(markdown.split(/\r?\n/)).state;
165
+ }
166
+ export function updateStatusBlock(markdown, newState, timestamp, reason) {
167
+ const lines = markdown.split(/\r?\n/);
168
+ const { stateLineIndex, state } = locateArtifactState(lines);
169
+ const previousState = state ?? 'unknown';
170
+ let lastUpdateLineIndex = -1;
171
+ for (const [index, raw] of lines.entries()) {
172
+ if (/^-\s*last update:\s*/.test(raw.trim())) {
92
173
  lastUpdateLineIndex = index;
93
174
  }
94
175
  }
@@ -101,6 +101,15 @@ export declare function readPerfTemplate(projectRoot: string): string | null;
101
101
  export declare function detectPerfAudit(input: {
102
102
  readonly projectRoot: string;
103
103
  readonly sessionId: string;
104
+ /**
105
+ * The slice whose capsule this run audits. Slice
106
+ * `2026-09-14-prd-capsule-rid-scoping` put the rid in the capsule's
107
+ * filename, so a caller that knows it must pass it or the probe resolves
108
+ * only the pre-rid-scoping bare name. Both callers pass it:
109
+ * `runPerfAudit` always did, and `peaks perf-audit detect` forwards its
110
+ * long-standing `--rid` flag as of the post-verification repair round.
111
+ */
112
+ readonly requestId?: string;
104
113
  readonly dispatchError?: unknown;
105
114
  readonly envelope?: unknown;
106
115
  }): PerfAuditDetectResult;
@@ -23,6 +23,8 @@
23
23
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
24
  import { join, resolve, isAbsolute } from 'node:path';
25
25
  import { createHash } from 'node:crypto';
26
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
27
+ import { resolveHandoffPath } from '../prd/handoff-service.js';
26
28
  /**
27
29
  * Validate a raw value as a PerfAuditEnvelope. Mirrors the
28
30
  * `isSecurityAuditEnvelope` strict-shape pattern.
@@ -134,8 +136,12 @@ export function readPerfTemplate(projectRoot) {
134
136
  export function detectPerfAudit(input) {
135
137
  const warnings = [];
136
138
  const nextActions = [];
137
- const handoffPath = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd', 'handoff.md');
138
- const handoffPresent = existsSync(handoffPath);
139
+ const handoffPath = resolveHandoffPath({
140
+ projectRoot: input.projectRoot,
141
+ sessionId: input.sessionId,
142
+ ...(input.requestId !== undefined ? { requestId: input.requestId } : {})
143
+ });
144
+ const handoffPresent = handoffPath !== null;
139
145
  const templatePath = join(input.projectRoot, '.peaks', 'project-scan', 'perf-template.md');
140
146
  const templatePresent = existsSync(templatePath);
141
147
  if (!handoffPresent) {
@@ -143,7 +149,12 @@ export function detectPerfAudit(input) {
143
149
  state: 'handoff-missing',
144
150
  handoffPresent: false,
145
151
  templatePresent,
146
- warnings: [`peaks-prd handoff not found at ${handoffPath}`],
152
+ warnings: [
153
+ `peaks-prd handoff not found under ${join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd')}`,
154
+ ...(input.requestId === undefined
155
+ ? ['No --rid was supplied, so only the pre-rid-scoping `prd/handoff.md` could be probed. Pass --rid to resolve this slice\'s `prd/handoff-<rid>.md`.']
156
+ : [])
157
+ ],
147
158
  nextActions: [
148
159
  'Run peaks-prd handoff init to produce a sha256-locked handoff before running peaks perf-audit.',
149
160
  'Until the handoff exists, peaks-perf-audit cannot start (gate fail).'
@@ -268,6 +279,12 @@ export function renderPerfAuditArtifact(env, opts) {
268
279
  * Returns the absolute path on success.
269
280
  */
270
281
  export function writePerfAuditArtifact(projectRoot, sessionId, rid, body) {
282
+ // The rid is a filename below, and the write is tmp+rename, so an
283
+ // unvalidated rid can OVERWRITE an arbitrary `.md` rather than merely create
284
+ // one. Guarded here (not only at the CLI boundary) so no caller can skip it.
285
+ if (!REQUEST_ID_PATTERN.test(rid)) {
286
+ throw new Error(`Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`);
287
+ }
271
288
  const targetDir = join(projectRoot, '.peaks', '_runtime', sessionId, 'audit');
272
289
  mkdirSync(targetDir, { recursive: true });
273
290
  const targetPath = join(targetDir, `perf-${rid}.md`);
@@ -290,6 +307,7 @@ export function runPerfAudit(input) {
290
307
  const detect = detectPerfAudit({
291
308
  projectRoot: input.projectRoot,
292
309
  sessionId: input.sessionId,
310
+ requestId: input.rid,
293
311
  ...(input.dispatchError !== undefined ? { dispatchError: input.dispatchError } : {}),
294
312
  ...(input.envelope !== undefined ? { envelope: input.envelope } : {})
295
313
  });
@@ -298,8 +316,12 @@ export function runPerfAudit(input) {
298
316
  }
299
317
  // detect.state === 'ready' implies input.envelope passed isPerfAuditEnvelope.
300
318
  const env = input.envelope;
301
- const handoffPath = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd', 'handoff.md');
302
- const verified = readAndVerifyHandoff(handoffPath, input.projectRoot);
319
+ const handoffPath = resolveHandoffPath({
320
+ projectRoot: input.projectRoot,
321
+ sessionId: input.sessionId,
322
+ requestId: input.rid
323
+ });
324
+ const verified = handoffPath === null ? null : readAndVerifyHandoff(handoffPath, input.projectRoot);
303
325
  const handoffHash = verified?.frontmatter.sha256 ?? 'unknown';
304
326
  const rendered = renderPerfAuditArtifact(env, {
305
327
  rid: input.rid,
@@ -8,7 +8,7 @@
8
8
  * The service owns:
9
9
  * - 5-state detection of the security-audit runtime
10
10
  * (handoff-missing / template-missing / dispatch-failed / template-malformed / ready)
11
- * - Loading + sha256 verification of the prd/handoff.md
11
+ * - Loading + sha256 verification of the slice's prd/handoff-<rid>.md
12
12
  * - Loading the project-level security-template.md
13
13
  * - Producing the audit envelope (verdict + violations) to write
14
14
  * to `.peaks/_runtime/<sid>/audit/security-<rid>.md`
@@ -36,7 +36,8 @@
36
36
  * 5-state detection result. Mirrors `detectEcc` in `services/code-review/ecc-bridge.ts`.
37
37
  *
38
38
  * - `ready` — handoff + template + project all present
39
- * - `handoff-missing` — `.peaks/_runtime/<sid>/prd/handoff.md` absent
39
+ * - `handoff-missing` — this slice's `.peaks/_runtime/<sid>/prd/handoff-<rid>.md`
40
+ * (or the pre-rid-scoping `prd/handoff.md`) absent
40
41
  * - `template-missing` — `.peaks/project-scan/security-template.md` absent
41
42
  * - `dispatch-failed` — parent LLM threw before returning the audit envelope
42
43
  * - `envelope-malformed` — parent LLM returned a value that fails `isSecurityAuditEnvelope`
@@ -107,6 +108,15 @@ export declare function readSecurityTemplate(projectRoot: string): string | null
107
108
  export declare function detectSecurityAudit(input: {
108
109
  readonly projectRoot: string;
109
110
  readonly sessionId: string;
111
+ /**
112
+ * The slice whose capsule this run audits. Slice
113
+ * `2026-09-14-prd-capsule-rid-scoping` put the rid in the capsule's
114
+ * filename, so a caller that knows it must pass it or the probe resolves
115
+ * only the pre-rid-scoping bare name. Both callers pass it:
116
+ * `runSecurityAudit` always did, and `peaks security-audit detect` forwards
117
+ * its long-standing `--rid` flag as of the post-verification repair round.
118
+ */
119
+ readonly requestId?: string;
110
120
  readonly dispatchError?: unknown;
111
121
  readonly envelope?: unknown;
112
122
  }): SecurityAuditDetectResult;
@@ -8,7 +8,7 @@
8
8
  * The service owns:
9
9
  * - 5-state detection of the security-audit runtime
10
10
  * (handoff-missing / template-missing / dispatch-failed / template-malformed / ready)
11
- * - Loading + sha256 verification of the prd/handoff.md
11
+ * - Loading + sha256 verification of the slice's prd/handoff-<rid>.md
12
12
  * - Loading the project-level security-template.md
13
13
  * - Producing the audit envelope (verdict + violations) to write
14
14
  * to `.peaks/_runtime/<sid>/audit/security-<rid>.md`
@@ -35,6 +35,8 @@
35
35
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
36
36
  import { join, resolve, isAbsolute } from 'node:path';
37
37
  import { createHash } from 'node:crypto';
38
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
39
+ import { resolveHandoffPath } from '../prd/handoff-service.js';
38
40
  /**
39
41
  * Validate a raw value as a SecurityAuditEnvelope. Mirrors the
40
42
  * `isEccEnvelope` strict-shape pattern.
@@ -145,8 +147,12 @@ export function readSecurityTemplate(projectRoot) {
145
147
  export function detectSecurityAudit(input) {
146
148
  const warnings = [];
147
149
  const nextActions = [];
148
- const handoffPath = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd', 'handoff.md');
149
- const handoffPresent = existsSync(handoffPath);
150
+ const handoffPath = resolveHandoffPath({
151
+ projectRoot: input.projectRoot,
152
+ sessionId: input.sessionId,
153
+ ...(input.requestId !== undefined ? { requestId: input.requestId } : {})
154
+ });
155
+ const handoffPresent = handoffPath !== null;
150
156
  const templatePath = join(input.projectRoot, '.peaks', 'project-scan', 'security-template.md');
151
157
  const templatePresent = existsSync(templatePath);
152
158
  if (!handoffPresent) {
@@ -154,7 +160,12 @@ export function detectSecurityAudit(input) {
154
160
  state: 'handoff-missing',
155
161
  handoffPresent: false,
156
162
  templatePresent,
157
- warnings: [`peaks-prd handoff not found at ${handoffPath}`],
163
+ warnings: [
164
+ `peaks-prd handoff not found under ${join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd')}`,
165
+ ...(input.requestId === undefined
166
+ ? ['No --rid was supplied, so only the pre-rid-scoping `prd/handoff.md` could be probed. Pass --rid to resolve this slice\'s `prd/handoff-<rid>.md`.']
167
+ : [])
168
+ ],
158
169
  nextActions: [
159
170
  'Run peaks-prd handoff init to produce a sha256-locked handoff before running peaks security-audit.',
160
171
  'Until the handoff exists, peaks-security-audit cannot start (gate fail).'
@@ -268,6 +279,12 @@ export function renderSecurityAuditArtifact(env, opts) {
268
279
  * Returns the absolute path on success.
269
280
  */
270
281
  export function writeSecurityAuditArtifact(projectRoot, sessionId, rid, body) {
282
+ // The rid is a filename below, and the write is tmp+rename, so an
283
+ // unvalidated rid can OVERWRITE an arbitrary `.md` rather than merely create
284
+ // one. Guarded here (not only at the CLI boundary) so no caller can skip it.
285
+ if (!REQUEST_ID_PATTERN.test(rid)) {
286
+ throw new Error(`Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`);
287
+ }
271
288
  const targetDir = join(projectRoot, '.peaks', '_runtime', sessionId, 'audit');
272
289
  mkdirSync(targetDir, { recursive: true });
273
290
  const targetPath = join(targetDir, `security-${rid}.md`);
@@ -290,6 +307,7 @@ export function runSecurityAudit(input) {
290
307
  const detect = detectSecurityAudit({
291
308
  projectRoot: input.projectRoot,
292
309
  sessionId: input.sessionId,
310
+ requestId: input.rid,
293
311
  ...(input.dispatchError !== undefined ? { dispatchError: input.dispatchError } : {}),
294
312
  ...(input.envelope !== undefined ? { envelope: input.envelope } : {})
295
313
  });
@@ -298,8 +316,12 @@ export function runSecurityAudit(input) {
298
316
  }
299
317
  // detect.state === 'ready' implies input.envelope passed isSecurityAuditEnvelope.
300
318
  const env = input.envelope;
301
- const handoffPath = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'prd', 'handoff.md');
302
- const verified = readAndVerifyHandoff(handoffPath, input.projectRoot);
319
+ const handoffPath = resolveHandoffPath({
320
+ projectRoot: input.projectRoot,
321
+ sessionId: input.sessionId,
322
+ requestId: input.rid
323
+ });
324
+ const verified = handoffPath === null ? null : readAndVerifyHandoff(handoffPath, input.projectRoot);
303
325
  const handoffHash = verified?.frontmatter.sha256 ?? 'unknown';
304
326
  const rendered = renderSecurityAuditArtifact(env, {
305
327
  rid: input.rid,
@@ -145,4 +145,123 @@ export declare function settleOpenLifecycleRun(input: {
145
145
  readonly triggerRatio: number;
146
146
  readonly afterRatio: number;
147
147
  } | null;
148
+ /**
149
+ * rid `2026-09-13-compact-event-settle`: close out an open compact run because
150
+ * the HARNESS said one completed — `PostCompact` — rather than because a later
151
+ * probe noticed the ratio had fallen.
152
+ *
153
+ * WHY THIS IS A SECOND FUNCTION AND NOT A FLAG ON THE ONE ABOVE. The function
154
+ * above is defined by two MEASUREMENT gates: it refuses when nothing could be
155
+ * measured, and refuses when the number it got has not dropped far enough. Both
156
+ * are correct for a probe, whose ratio is an INFERENCE about whether something
157
+ * happened. Handed a harness event, both are wrong in the same direction —
158
+ * the harness has already stated that the compaction happened, so a probe that
159
+ * could not measure, or measured something larger, contradicts nothing. The
160
+ * event is the evidence; the ratio is a consequence.
161
+ *
162
+ * What survives from the probe path is the ATTRIBUTION gate, and only that:
163
+ * there must be an open run (`compacting` / `armed`) for this event to be
164
+ * about. A `PostCompact` on a session where peaks-loop never dispatched has
165
+ * nothing to settle — objectively, the run the event would complete does not
166
+ * exist. (`queued` / `preparing` are excluded for the probe path's reason: a
167
+ * run that died before dispatch never had a compaction to complete.)
168
+ *
169
+ * `afterRatio` is recorded ONLY when it is a genuine DROP below the ratio the
170
+ * dispatch was made at. Immediately after a compaction, `readContextPercent`
171
+ * prefers the statusline file, which may still hold the PRE-compact value; the
172
+ * one thing this row must not do is launder that stale reading into an
173
+ * `afterRatio` and publish "the context did not shrink" as a measurement. A
174
+ * `null` here means "no honest post-compact number was available at the moment
175
+ * the event fired" — and that is NOT self-healing: the record is left at
176
+ * `completed` with no number, `computeWindowCalibration` skips `observed` rows
177
+ * that carry none, and the probe path refuses a run that is no longer open. The
178
+ * pair is then closed by `fillEventSettledMeasurement` below — but only on the
179
+ * probes that reach it, which is not all of them: that call sits in the
180
+ * BELOW-THRESHOLD branch of `runAutoCompact` (`auto-compact-orchestrator.ts:567`
181
+ * guards it, `:589` calls it). A probe that instead commits to compacting does
182
+ * not merely defer the measurement: `advance('queued')` writes a fresh run to
183
+ * the same one-record-per-session store (`auto-compact-orchestrator.ts:668`), so
184
+ * the `completed`-without-`afterRatio` record this pair was owed is gone and the
185
+ * pair stays unmeasured. That loss is inherent rather than an oversight — once a
186
+ * second compaction has happened, no later ratio can be attributed to the first,
187
+ * so there is nothing honest left to fill — and it is visible as `unmeasured` in
188
+ * `peaks compact history` (QA residual R9). A fabricated number is not
189
+ * recoverable at all, which is why the stale reading is dropped rather than
190
+ * corrected.
191
+ *
192
+ * `verifying` is deliberately NOT emitted: its documented meaning is "we hold a
193
+ * measurement and are checking it", and on this path there may be no
194
+ * measurement at all. Emitting it would move the same untruth from the history
195
+ * row into the lifecycle record.
196
+ *
197
+ * Returns the settled facts, or `null` when there was nothing to settle. `null`
198
+ * means exactly ONE thing here — there was no OPEN run for this event to be
199
+ * about. A run that was open but whose record could not be written is not
200
+ * `null`: it returns the facts read off that run with `lifecycleWritten: false`,
201
+ * because a failed write is not an absent run, and a caller that cannot tell the
202
+ * two apart ends up telling the user a falsehood (see `compact-event-settle.ts`).
203
+ */
204
+ export declare function settleOpenLifecycleRunOnCompactEvent(input: {
205
+ readonly projectRoot: string;
206
+ readonly sessionId: string;
207
+ readonly measuredRatio: number | null;
208
+ readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
209
+ /** Failure injection for the write below — the seam `CompactLifecyclePublisher` already takes. */
210
+ readonly failLifecycleWrite?: boolean | undefined;
211
+ }): {
212
+ readonly runId: string;
213
+ readonly triggerRatio: number;
214
+ readonly afterRatio: number | null;
215
+ /**
216
+ * `false` when the run could not be marked settled — `writeCompactLifecycle`
217
+ * threw. The three facts above were read off the OPEN run, so they stay true
218
+ * and a caller may still record the observation; what it must not do is report
219
+ * the run as settled. `settleOpenLifecycleRun` above does not suppress its
220
+ * returned record on a failed write either, so the two now agree that a write
221
+ * failure is not "nothing happened". This field exists because this function's
222
+ * caller, unlike the sibling's, renders a sentence about the outcome.
223
+ */
224
+ readonly lifecycleWritten: boolean;
225
+ } | null;
226
+ /**
227
+ * rid `2026-09-13-compact-event-settle` (repair R1): supply the measurement a
228
+ * harness-settled run was left owing.
229
+ *
230
+ * The function above deliberately refuses to launder a post-compact reading
231
+ * that has not dropped — and right after a compaction that refusal is the
232
+ * NORMAL case, because the statusline still holds the pre-compact value. The
233
+ * run is then closed at `completed` with no `afterRatio`, so the dispatch's
234
+ * calibration pair never closes and "intent vs observed" stays blank for
235
+ * exactly the compactions this slice exists to witness. This function is what
236
+ * makes the function above's promise payable.
237
+ *
238
+ * WHY NOT WIDEN `settleOpenLifecycleRun`. That one re-emits `verifying` before
239
+ * `completed`, which on an already-`completed` record is a backwards stage
240
+ * transition with no observer to serve. This is not a settlement — the run IS
241
+ * settled; only the number is owed. So no stage is rewritten here.
242
+ *
243
+ * WRITING `afterRatio` ONTO THE RECORD IS THE IDEMPOTENCE TOKEN: every later
244
+ * probe finds it present and returns `null`, so however many probes follow, one
245
+ * compaction yields exactly one late measurement.
246
+ *
247
+ * THE DROP GATE IS THE EVENT PATH'S OWN (`measuredRatio < triggerRatio`), not
248
+ * the probe path's `autoFireThreshold`. `afterRatio` has to mean "below the
249
+ * ratio this run was dispatched at" — the rule the event path already enforces
250
+ * — or a run dispatched under the threshold (a forced or banded dispatch) would
251
+ * let a NON-drop through the one path that can still write a `completed` record.
252
+ * `conservative-fallback` is refused for the probe path's reason: its `0` is
253
+ * the absence of a measurement, not an empty context.
254
+ *
255
+ * Returns the filled record, or `null` when no run is owed a measurement.
256
+ */
257
+ export declare function fillEventSettledMeasurement(input: {
258
+ readonly projectRoot: string;
259
+ readonly sessionId: string;
260
+ readonly measuredRatio: number;
261
+ readonly source: string;
262
+ }): {
263
+ readonly runId: string;
264
+ readonly triggerRatio: number;
265
+ readonly afterRatio: number;
266
+ } | null;
148
267
  export {};