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
@@ -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
  }
@@ -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
  }
@@ -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,
@@ -51,6 +51,65 @@ export declare function resolveDispatchedStage(input: {
51
51
  * attempts inside the same millisecond distinct.
52
52
  */
53
53
  export declare function newCompactRunId(now: Date): string;
54
+ /**
55
+ * rid `2026-09-14-compact-dispatch-backoff`: the compact run this session has
56
+ * already DISPATCHED and whose outcome is still unknown — the backoff token.
57
+ *
58
+ * WHY A BACKOFF IS NEEDED AT ALL. Once the ratio crosses the auto-fire
59
+ * threshold it STAYS crossed: nothing in peaks-loop can compact a running
60
+ * session, and the harness fires only at its own red line. So the dispatch
61
+ * obligation became unsatisfiable and fired on every probe. Measured in one
62
+ * real session (2026-09-13T22:43:33Z → 2026-09-14T14:15:02Z, ~15.5 h):
63
+ * 1035 `dispatch` rows, 444 checkpoints, and ZERO compactions — 1075 by
64
+ * 14:22:09.112Z. The dispatch itself is idempotent — `ide-native` only
65
+ * installs a PreToolUse hook, and a second install of the same hook is a
66
+ * documented no-op — so 1074 of those rows installed nothing (their own
67
+ * `dispatchMessage` says `already installed`) and carried no new information.
68
+ * Only noise: a signal that fires a thousand times is not a signal.
69
+ *
70
+ * WHY NO NEW STORE. The one-record-per-session lifecycle store already holds
71
+ * the one fact the backoff needs: is a compact attempt dispatched and not yet
72
+ * superseded? `armed` and `compacting` are exactly those two stages, and
73
+ * `completed` / `failed` mean the attempt is over and the next crossing is a
74
+ * legitimate new one. The state lives here rather than in
75
+ * `compact-history.jsonl`, which is append-only and never rewritten — so the
76
+ * backoff cannot be built by editing history, which is the point.
77
+ *
78
+ * STALENESS IS DELIBERATELY IGNORED, for `settleOpenLifecycleRun`'s reason: a
79
+ * probe arriving after `staleAfterMs` has learned nothing about whether the run
80
+ * is still live. Age cannot make re-dispatching honest, so the stale window
81
+ * must not decide it. (`settleOpenLifecycleRun` reads with the same window for
82
+ * the same reason; the two must not grow two policies.)
83
+ *
84
+ * `queued` / `preparing` / `verifying` are NOT open here: they are transient
85
+ * stages inside a dispatch or a settle, and a process that died in one of them
86
+ * left no compact attempt to protect — the next probe should be free to
87
+ * dispatch. `failed` is not open either, by the same argument: a failure is a
88
+ * reason to try again, not a reason to stay quiet.
89
+ */
90
+ /**
91
+ * The answer to "is a compact attempt outstanding for this session?".
92
+ *
93
+ * `none` and `unresolvable` must not be the same value. The caller uses
94
+ * `none` to ADMIT a dispatch; `unresolvable` means the question could
95
+ * not be asked, and a caller that admits on "could not ask" has a gate
96
+ * that reads a string rather than the artifact it names.
97
+ */
98
+ export type OpenDispatchRunRead = {
99
+ readonly kind: 'open';
100
+ readonly runId: string;
101
+ readonly stage: 'armed' | 'compacting';
102
+ readonly triggerRatio: number;
103
+ } | {
104
+ readonly kind: 'none';
105
+ } | {
106
+ readonly kind: 'unresolvable';
107
+ readonly reason: string;
108
+ };
109
+ export declare function readOpenDispatchRun(input: {
110
+ readonly projectRoot: string;
111
+ readonly sessionId: string;
112
+ }): OpenDispatchRunRead;
54
113
  /**
55
114
  * Slice 2026-08-01-compact-lifecycle (Task 5): the local transition
56
115
  * builder. Carries `runId`, `triggerRatio` and `redLine` forward from
@@ -132,6 +191,18 @@ export declare function summarizeLifecycleError(error: unknown): string;
132
191
  * `afterRatio` to append the "observed compaction point" row that makes the
133
192
  * intent-vs-observed gap readable after a real session. The return value is
134
193
  * telemetry only — callers that ignore it are unaffected.
194
+ *
195
+ * `lifecycleWritten` (repair R6) makes the returned record honest about
196
+ * whether the RUN actually moved. Before it, a failed write still returned
197
+ * the full envelope, so a store that could not be written reported a settle
198
+ * on every probe, forever: the run stayed `armed`, the next probe found it
199
+ * open again, and each one added another "observed compaction point" row —
200
+ * an unbounded append driven by a write that never happened. A failed write
201
+ * is not an absent run (the facts are real and stay true, so the caller may
202
+ * still record the measurement), but it is also not a settled one. This is
203
+ * the same field `settleOpenLifecycleRunOnCompactEvent` carries, for the
204
+ * same reason, so the two settle paths no longer disagree about what a write
205
+ * failure means.
135
206
  */
136
207
  export declare function settleOpenLifecycleRun(input: {
137
208
  readonly projectRoot: string;
@@ -140,6 +211,129 @@ export declare function settleOpenLifecycleRun(input: {
140
211
  readonly source: string;
141
212
  readonly autoFireThreshold: number;
142
213
  readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
214
+ /** Failure injection for the write below — the seam `CompactLifecyclePublisher` already takes. */
215
+ readonly failLifecycleWrite?: boolean | undefined;
216
+ }): {
217
+ readonly runId: string;
218
+ readonly triggerRatio: number;
219
+ readonly afterRatio: number;
220
+ /** `false` when the `completed` write threw, so the run is STILL open. */
221
+ readonly lifecycleWritten: boolean;
222
+ } | null;
223
+ /**
224
+ * rid `2026-09-13-compact-event-settle`: close out an open compact run because
225
+ * the HARNESS said one completed — `PostCompact` — rather than because a later
226
+ * probe noticed the ratio had fallen.
227
+ *
228
+ * WHY THIS IS A SECOND FUNCTION AND NOT A FLAG ON THE ONE ABOVE. The function
229
+ * above is defined by two MEASUREMENT gates: it refuses when nothing could be
230
+ * measured, and refuses when the number it got has not dropped far enough. Both
231
+ * are correct for a probe, whose ratio is an INFERENCE about whether something
232
+ * happened. Handed a harness event, both are wrong in the same direction —
233
+ * the harness has already stated that the compaction happened, so a probe that
234
+ * could not measure, or measured something larger, contradicts nothing. The
235
+ * event is the evidence; the ratio is a consequence.
236
+ *
237
+ * What survives from the probe path is the ATTRIBUTION gate, and only that:
238
+ * there must be an open run (`compacting` / `armed`) for this event to be
239
+ * about. A `PostCompact` on a session where peaks-loop never dispatched has
240
+ * nothing to settle — objectively, the run the event would complete does not
241
+ * exist. (`queued` / `preparing` are excluded for the probe path's reason: a
242
+ * run that died before dispatch never had a compaction to complete.)
243
+ *
244
+ * `afterRatio` is recorded ONLY when it is a genuine DROP below the ratio the
245
+ * dispatch was made at. Immediately after a compaction, `readContextPercent`
246
+ * prefers the statusline file, which may still hold the PRE-compact value; the
247
+ * one thing this row must not do is launder that stale reading into an
248
+ * `afterRatio` and publish "the context did not shrink" as a measurement. A
249
+ * `null` here means "no honest post-compact number was available at the moment
250
+ * the event fired" — and that is NOT self-healing: the record is left at
251
+ * `completed` with no number, `computeWindowCalibration` skips `observed` rows
252
+ * that carry none, and the probe path refuses a run that is no longer open. The
253
+ * pair is then closed by `fillEventSettledMeasurement` below — but only on the
254
+ * probes that reach it, which is not all of them: that call sits in the
255
+ * BELOW-THRESHOLD branch of `runAutoCompact` (`auto-compact-orchestrator.ts:567`
256
+ * guards it, `:589` calls it). A probe that instead commits to compacting does
257
+ * not merely defer the measurement: `advance('queued')` writes a fresh run to
258
+ * the same one-record-per-session store (`auto-compact-orchestrator.ts:668`), so
259
+ * the `completed`-without-`afterRatio` record this pair was owed is gone and the
260
+ * pair stays unmeasured. That loss is inherent rather than an oversight — once a
261
+ * second compaction has happened, no later ratio can be attributed to the first,
262
+ * so there is nothing honest left to fill — and it is visible as `unmeasured` in
263
+ * `peaks compact history` (QA residual R9). A fabricated number is not
264
+ * recoverable at all, which is why the stale reading is dropped rather than
265
+ * corrected.
266
+ *
267
+ * `verifying` is deliberately NOT emitted: its documented meaning is "we hold a
268
+ * measurement and are checking it", and on this path there may be no
269
+ * measurement at all. Emitting it would move the same untruth from the history
270
+ * row into the lifecycle record.
271
+ *
272
+ * Returns the settled facts, or `null` when there was nothing to settle. `null`
273
+ * means exactly ONE thing here — there was no OPEN run for this event to be
274
+ * about. A run that was open but whose record could not be written is not
275
+ * `null`: it returns the facts read off that run with `lifecycleWritten: false`,
276
+ * because a failed write is not an absent run, and a caller that cannot tell the
277
+ * two apart ends up telling the user a falsehood (see `compact-event-settle.ts`).
278
+ */
279
+ export declare function settleOpenLifecycleRunOnCompactEvent(input: {
280
+ readonly projectRoot: string;
281
+ readonly sessionId: string;
282
+ readonly measuredRatio: number | null;
283
+ readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
284
+ /** Failure injection for the write below — the seam `CompactLifecyclePublisher` already takes. */
285
+ readonly failLifecycleWrite?: boolean | undefined;
286
+ }): {
287
+ readonly runId: string;
288
+ readonly triggerRatio: number;
289
+ readonly afterRatio: number | null;
290
+ /**
291
+ * `false` when the run could not be marked settled — `writeCompactLifecycle`
292
+ * threw. The three facts above were read off the OPEN run, so they stay true
293
+ * and a caller may still record the observation; what it must not do is report
294
+ * the run as settled. `settleOpenLifecycleRun` above does not suppress its
295
+ * returned record on a failed write either, so the two now agree that a write
296
+ * failure is not "nothing happened". This field exists because this function's
297
+ * caller, unlike the sibling's, renders a sentence about the outcome.
298
+ */
299
+ readonly lifecycleWritten: boolean;
300
+ } | null;
301
+ /**
302
+ * rid `2026-09-13-compact-event-settle` (repair R1): supply the measurement a
303
+ * harness-settled run was left owing.
304
+ *
305
+ * The function above deliberately refuses to launder a post-compact reading
306
+ * that has not dropped — and right after a compaction that refusal is the
307
+ * NORMAL case, because the statusline still holds the pre-compact value. The
308
+ * run is then closed at `completed` with no `afterRatio`, so the dispatch's
309
+ * calibration pair never closes and "intent vs observed" stays blank for
310
+ * exactly the compactions this slice exists to witness. This function is what
311
+ * makes the function above's promise payable.
312
+ *
313
+ * WHY NOT WIDEN `settleOpenLifecycleRun`. That one re-emits `verifying` before
314
+ * `completed`, which on an already-`completed` record is a backwards stage
315
+ * transition with no observer to serve. This is not a settlement — the run IS
316
+ * settled; only the number is owed. So no stage is rewritten here.
317
+ *
318
+ * WRITING `afterRatio` ONTO THE RECORD IS THE IDEMPOTENCE TOKEN: every later
319
+ * probe finds it present and returns `null`, so however many probes follow, one
320
+ * compaction yields exactly one late measurement.
321
+ *
322
+ * THE DROP GATE IS THE EVENT PATH'S OWN (`measuredRatio < triggerRatio`), not
323
+ * the probe path's `autoFireThreshold`. `afterRatio` has to mean "below the
324
+ * ratio this run was dispatched at" — the rule the event path already enforces
325
+ * — or a run dispatched under the threshold (a forced or banded dispatch) would
326
+ * let a NON-drop through the one path that can still write a `completed` record.
327
+ * `conservative-fallback` is refused for the probe path's reason: its `0` is
328
+ * the absence of a measurement, not an empty context.
329
+ *
330
+ * Returns the filled record, or `null` when no run is owed a measurement.
331
+ */
332
+ export declare function fillEventSettledMeasurement(input: {
333
+ readonly projectRoot: string;
334
+ readonly sessionId: string;
335
+ readonly measuredRatio: number;
336
+ readonly source: string;
143
337
  }): {
144
338
  readonly runId: string;
145
339
  readonly triggerRatio: number;