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
@@ -0,0 +1,332 @@
1
+ /**
2
+ * rid 2026-09-14-gate-h-promotion (R2; repaired by R8) — what a promotion's
3
+ * artifact has to PROVE.
4
+ *
5
+ * The three layers used to be checked with `text.includes(<rule name>)` over the
6
+ * whole file. A substring test cannot tell "the rule is registered" from "a
7
+ * comment saying the rule is absent", so a refusal tree passed every layer:
8
+ *
9
+ * - layer A: a `.peaks/sops/registry.json` that is not valid JSON, with the id
10
+ * still somewhere in its bytes;
11
+ * - layer B: a settings template whose only mention of the rule reads
12
+ * "do NOT add a matcher for <rule>";
13
+ * - layer C: a `mode-gate.ts` whose only mention reads
14
+ * "// TODO: <rule> is DELIBERATELY NOT a hard-floor category".
15
+ *
16
+ * All three are the same defect facing the other way: an unreadable artifact is
17
+ * treated as a permitted one. Each check below therefore parses its evidence and
18
+ * asserts the SHAPE, and every failure is a finding — never a warning that
19
+ * permits. Nothing here throws: an unparseable file is reported, not swallowed.
20
+ *
21
+ * R8 (same rid) — the same defect survived inside R2's repair, in two places:
22
+ *
23
+ * - layer C matched a member's `doc` by name MENTION, so an adverse line
24
+ * sitting between two vocabulary members became the following member's doc
25
+ * and the verdict flipped on where the comment sat. The predicate is now this
26
+ * repo's own citation form — the memory cited by PATH,
27
+ * `.peaks/memory/<id>.md`, exactly as `mode-gate.ts:41` does — rather than
28
+ * the memory merely named. That citation requirement is what closes the
29
+ * placement dependence; the doc is also read from the comment SPANS attached
30
+ * to the member rather than from the raw slice, which states "code is not a
31
+ * doc" as an invariant rather than relying on the raw slice to honour it;
32
+ * - layer B kept `matcher.includes(id)` / `command.includes(id)` over free
33
+ * text, so `command: "echo 'do NOT add a matcher for rule-x'"` registered a
34
+ * rule by writing a sentence about it. Both fields are now parsed: a matcher
35
+ * is a tool selector, a command is argv.
36
+ *
37
+ * R10 (same rid) — layer C read the `HardFloorCategory` union and the
38
+ * `HARD_FLOOR_CATEGORIES` array as ONE flattened member list, so a member of the
39
+ * union alone satisfied the check. Measured on the R8 bytes: a union-only literal
40
+ * — and a union-only member whose doc cited `.peaks/memory/<id>.md` — were both
41
+ * reported BACKED, while `isHardFloorCategory` (which reads the array) returned
42
+ * false and `shouldPauseAtGate` returned `shouldPause: false`. The gate certified
43
+ * a hard floor that paused nothing, contradicting its own message. Only the array
44
+ * enforces, so only the array is now evidence — for the name and for the citation
45
+ * alike. The union is still READ, for one reason only: a category's doc belongs
46
+ * to the category, and this repo documents `commit-boundary-side-effect` beside
47
+ * the union member while the array enforces it.
48
+ */
49
+ /** Is `value` a JSON object (not null, not an array)? */
50
+ function isRecord(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ }
53
+ function manifestFailure(parsed, id) {
54
+ if (!isRecord(parsed))
55
+ return 'not a JSON object';
56
+ if (parsed.id !== id)
57
+ return `does not declare id "${id}"`;
58
+ if (!Array.isArray(parsed.gates))
59
+ return 'has no "gates" array';
60
+ return null;
61
+ }
62
+ function registryFailure(parsed, id) {
63
+ if (!isRecord(parsed))
64
+ return 'not a JSON object';
65
+ if (!Array.isArray(parsed.sops))
66
+ return 'has no "sops" array';
67
+ const registered = parsed.sops.some((entry) => isRecord(entry) && entry.id === id);
68
+ return registered ? null : `registry has no SOP entry with id "${id}"`;
69
+ }
70
+ /**
71
+ * A tool selector: `Name` or `Name(pattern)`, one or more separated by `|` —
72
+ * `Bash`, `Write|Edit|MultiEdit`, `Bash(git push:*)`. A segment that is not a
73
+ * tool-name token (a hyphenated rule name, say) makes the selector malformed.
74
+ */
75
+ const MATCHER_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*\s*(\(.*\))?$/;
76
+ function isToolMatcher(matcher) {
77
+ const segments = matcher.split('|');
78
+ return segments.every((segment) => MATCHER_SEGMENT_RE.test(segment.trim()));
79
+ }
80
+ /**
81
+ * Split a hook command the way a shell does: whitespace separates words, and
82
+ * quotes group whitespace INTO a word rather than ending it. So a phrase inside
83
+ * a string literal arrives as one long word, while a path arrives short.
84
+ */
85
+ function commandWords(command) {
86
+ const words = [];
87
+ let current = '';
88
+ let started = false;
89
+ let quote = null;
90
+ for (let i = 0; i < command.length; i += 1) {
91
+ const char = command[i];
92
+ if (char === '\\' && quote !== "'" && i + 1 < command.length) {
93
+ current += command[i + 1];
94
+ started = true;
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (quote !== null) {
99
+ if (char === quote)
100
+ quote = null;
101
+ else
102
+ current += char;
103
+ continue;
104
+ }
105
+ if (char === '"' || char === "'") {
106
+ quote = char;
107
+ started = true;
108
+ continue;
109
+ }
110
+ if (/\s/.test(char)) {
111
+ if (started)
112
+ words.push(current);
113
+ current = '';
114
+ started = false;
115
+ continue;
116
+ }
117
+ current += char;
118
+ started = true;
119
+ }
120
+ if (started)
121
+ words.push(current);
122
+ return words;
123
+ }
124
+ const SCRIPT_EXTENSION_RE = /\.(?:c|m)?[jt]s$|\.(?:sh|bash|ps1|py|rb)$/i;
125
+ /**
126
+ * Does this hook `command` RUN something named after the rule?
127
+ *
128
+ * A command is argv, not prose. The rule counts only when its name sits inside a
129
+ * single word that (a) is not a phrase — no embedded whitespace, so a sentence
130
+ * inside a string literal is out — and (b) designates a file the hook executes:
131
+ * a path, or a script by extension.
132
+ *
133
+ * `echo 'do NOT add a matcher for rule-x'` parses to two words, the second of
134
+ * which holds the rule's name and four spaces: a sentence ABOUT the rule, not an
135
+ * invocation of it. `node scripts/enforce-rule-x.js` parses to two words, the
136
+ * second a path. That difference is the check.
137
+ */
138
+ function commandRegistersRule(command, id) {
139
+ return commandWords(command).some((word) => {
140
+ if (/\s/.test(word))
141
+ return false;
142
+ if (!word.includes(id))
143
+ return false;
144
+ return word.includes('/') || word.includes('\\') || SCRIPT_EXTENSION_RE.test(word);
145
+ });
146
+ }
147
+ /**
148
+ * Layer B. A hook group is `{ matcher?, hooks: [...] }`. The matcher SELECTS
149
+ * TOOLS, so it can never name a rule: a group whose selector is not a tool
150
+ * matcher fires nothing and registers nothing. The registration itself is a hook
151
+ * command that runs something named after the rule — prose elsewhere in the file
152
+ * (an `env` note saying "do NOT add a matcher") is not a registration, and
153
+ * neither is a sentence in a command.
154
+ */
155
+ function hookFailure(parsed, id) {
156
+ if (!isRecord(parsed))
157
+ return 'not a JSON object';
158
+ if (!isRecord(parsed.hooks))
159
+ return 'has no "hooks" object';
160
+ for (const groups of Object.values(parsed.hooks)) {
161
+ if (!Array.isArray(groups))
162
+ continue;
163
+ for (const group of groups) {
164
+ if (!isRecord(group))
165
+ continue;
166
+ if (typeof group.matcher === 'string' && !isToolMatcher(group.matcher))
167
+ continue;
168
+ const hooks = group.hooks;
169
+ if (!Array.isArray(hooks))
170
+ continue;
171
+ const registers = hooks.some((h) => isRecord(h) && typeof h.command === 'string' && commandRegistersRule(h.command, id));
172
+ if (registers)
173
+ return null;
174
+ }
175
+ }
176
+ return `no hook registration runs anything named "${id}" (a hook command must invoke a file named after the rule)`;
177
+ }
178
+ /**
179
+ * One pattern, used by BOTH the masker and the doc reader, so a comment can
180
+ * never be blanked by one and read as a doc by the other.
181
+ */
182
+ const COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
183
+ /** Comments are blanked to spaces, which keeps every offset in the file valid. */
184
+ function maskComments(text) {
185
+ return text.replace(COMMENT_RE, (comment) => ' '.repeat(comment.length));
186
+ }
187
+ function commentSpans(source) {
188
+ const spans = [];
189
+ COMMENT_RE.lastIndex = 0;
190
+ let match;
191
+ while ((match = COMMENT_RE.exec(source)) !== null) {
192
+ spans.push({ start: match.index, end: match.index + match[0].length, text: match[0] });
193
+ }
194
+ return spans;
195
+ }
196
+ /**
197
+ * Collect the members of the hard-floor vocabulary, each with the comment text
198
+ * that governs it. `masked` decides where a member STARTS (so a quoted string
199
+ * inside a comment is not a member, and no comment can inject the `;` / `]` that
200
+ * bounds a declaration); the doc is read from the comment SPANS inside the
201
+ * member's slot. Taking it from the raw slice instead — as R2 did — let CODE
202
+ * between two members become a doc, which is what made the verdict depend on
203
+ * where an adverse line sat.
204
+ */
205
+ function collectMembers(masked, comments, start, end, enforcing, out) {
206
+ const body = masked.slice(start, end);
207
+ const literalRe = /'([^']*)'/g;
208
+ let match;
209
+ let previousEnd = 0;
210
+ while ((match = literalRe.exec(body)) !== null) {
211
+ // A member literal is preceded by nothing but whitespace, a `|` (union) or a
212
+ // `,` (array). Anything else — a `'` inside prose, say — is not a member.
213
+ if (/^[\s|,]*$/.test(body.slice(previousEnd, match.index))) {
214
+ const from = start + previousEnd;
215
+ const to = start + match.index;
216
+ out.push({
217
+ literal: match[1],
218
+ doc: comments
219
+ .filter((comment) => comment.start >= from && comment.end <= to)
220
+ .map((comment) => comment.text)
221
+ .join('\n'),
222
+ enforcing
223
+ });
224
+ }
225
+ previousEnd = match.index + match[0].length;
226
+ }
227
+ }
228
+ /**
229
+ * The two declarations that spell the vocabulary: the `HardFloorCategory` union
230
+ * (a type) and `HARD_FLOOR_CATEGORIES` (the array `isHardFloorCategory` reads).
231
+ * They are the same vocabulary, but only the ARRAY enforces — see
232
+ * `mergeByLiteral` for why the union is still read.
233
+ */
234
+ function vocabularyMembers(source) {
235
+ const masked = maskComments(source);
236
+ const comments = commentSpans(source);
237
+ const raw = [];
238
+ const union = /export\s+type\s+HardFloorCategory\s*=/.exec(masked);
239
+ if (union) {
240
+ const start = union.index + union[0].length;
241
+ const end = masked.indexOf(';', start);
242
+ if (end > start)
243
+ collectMembers(masked, comments, start, end, false, raw);
244
+ }
245
+ const array = /HARD_FLOOR_CATEGORIES\s*:/.exec(masked);
246
+ if (array) {
247
+ const equals = masked.indexOf('=', array.index);
248
+ const open = masked.indexOf('[', equals);
249
+ const close = masked.indexOf(']', open + 1);
250
+ if (equals > array.index && open > equals && close > open) {
251
+ collectMembers(masked, comments, open + 1, close, true, raw);
252
+ }
253
+ }
254
+ return mergeByLiteral(raw);
255
+ }
256
+ /**
257
+ * R10 — one entry per literal, and `enforcing` is only ever set by the array.
258
+ *
259
+ * Reading the two declarations as one flat member list let a member of the
260
+ * `HardFloorCategory` union ALONE satisfy this check while
261
+ * `isHardFloorCategory` — and therefore `shouldPauseAtGate` — did not recognise
262
+ * it. The gate reported BACKED for a hard floor that paused nothing. Splitting
263
+ * the provenance of `enforcing` out of the member list is what makes the two
264
+ * agree: what the predicate accepts is exactly what the array contains.
265
+ *
266
+ * The docs are joined across occurrences rather than kept per-declaration,
267
+ * because a category's doc belongs to the CATEGORY. This repo's own layer-C
268
+ * promotion cites `.peaks/memory/2026-06-28-full-auto-boundary.md` from the
269
+ * comment beside the UNION member of `commit-boundary-side-effect`, while the
270
+ * ARRAY is what enforces it; reading the doc from one declaration only would
271
+ * make that citation unreadable — a false negative on a file already known good.
272
+ */
273
+ function mergeByLiteral(members) {
274
+ const merged = new Map();
275
+ for (const member of members) {
276
+ const previous = merged.get(member.literal);
277
+ merged.set(member.literal, {
278
+ literal: member.literal,
279
+ doc: [previous?.doc, member.doc].filter((doc) => doc !== undefined && doc !== '').join('\n'),
280
+ enforcing: (previous?.enforcing ?? false) || member.enforcing
281
+ });
282
+ }
283
+ return [...merged.values()];
284
+ }
285
+ /** A memory cited by path, the way this repo cites one (`mode-gate.ts:41`). */
286
+ const MEMORY_CITATION_RE = /\.peaks\/memory\/([A-Za-z0-9._-]+)\.md/g;
287
+ function citedMemories(doc) {
288
+ return Array.from(doc.matchAll(MEMORY_CITATION_RE), (match) => match[1]);
289
+ }
290
+ /**
291
+ * Layer C. The rule counts when it IS a member of `HARD_FLOOR_CATEGORIES`, or
292
+ * when one of THAT ARRAY's members has a doc block that CITES it the way this
293
+ * repo cites a memory — by path: `commit-boundary-side-effect` cites
294
+ * `.peaks/memory/2026-06-28-full-auto-boundary.md`.
295
+ *
296
+ * R10 — both clauses are gated on `enforcing`. Certifying membership in the
297
+ * `HardFloorCategory` union alone is what let the check report BACKED for a
298
+ * category that `isHardFloorCategory` rejects and `shouldPauseAtGate` ignores.
299
+ *
300
+ * A citation is a deliberate act with a shape; a name is a word that any
301
+ * sentence can carry. `// TODO: <rule> is DELIBERATELY NOT a hard-floor
302
+ * category` names the rule while saying the opposite, and is rejected in every
303
+ * placement — the predicate never reads the sentence the citation sits in, so
304
+ * prose that negates a citation is a residual this cannot see. What it does see
305
+ * is the difference between being cited and being mentioned.
306
+ */
307
+ function hardFloorFailure(source, id) {
308
+ const registered = vocabularyMembers(source).some((member) => member.enforcing && (member.literal === id || citedMemories(member.doc).includes(id)));
309
+ return registered
310
+ ? null
311
+ : `no hard-floor category names "${id}" (it must be a member of HARD_FLOOR_CATEGORIES, or cited by one as ".peaks/memory/${id}.md")`;
312
+ }
313
+ /**
314
+ * The reason `check` is unsatisfied by `text`, or `null` when it is satisfied.
315
+ * Never throws: a file that cannot be parsed yields the reason it could not be.
316
+ */
317
+ export function artifactEvidenceFailure(check, text) {
318
+ if (check.evidence === 'hard-floor-category')
319
+ return hardFloorFailure(text, check.id);
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(text);
323
+ }
324
+ catch (err) {
325
+ return `not valid JSON: ${err.message}`;
326
+ }
327
+ if (check.evidence === 'sop-manifest')
328
+ return manifestFailure(parsed, check.id);
329
+ if (check.evidence === 'sop-registry-entry')
330
+ return registryFailure(parsed, check.id);
331
+ return hookFailure(parsed, check.id);
332
+ }
@@ -252,6 +252,15 @@ interface EvidenceSource {
252
252
  readonly label: string;
253
253
  /** Path segments under `.peaks/_runtime/<sessionId>/`. */
254
254
  readonly segments: readonly string[];
255
+ /**
256
+ * An older location of the SAME artifact, tried only when `segments` is not
257
+ * on disk. Slice `2026-09-14-prd-capsule-rid-scoping` moved the PRD handoff
258
+ * capsule to `prd/handoff-<rid>.md`; sessions written before it hold only
259
+ * the bare `prd/handoff.md`, and this module's delivery gate keys on that
260
+ * source — so a source that goes missing does not fail the gate, it stops
261
+ * it (`enforceScopeContractDelivery` returns early on `missing`).
262
+ */
263
+ readonly legacySegments?: readonly string[];
255
264
  /** Dimensions this source can supply evidence for. */
256
265
  readonly supports: readonly DimensionKind[];
257
266
  /** What DELIVERED means for this source. See `isDelivered()` — every source
@@ -474,7 +474,10 @@ function evidenceSourcesFor(rid, prePostDiffAvailable) {
474
474
  {
475
475
  key: SCOPE_CONTRACT_SOURCE_KEY,
476
476
  label: 'PRD handoff (approved scope + non-goals)',
477
- segments: ['prd', 'handoff.md'],
477
+ // One capsule per slice since `2026-09-14-prd-capsule-rid-scoping`; the
478
+ // bare name is the pre-scoping tier and still lives on 3 sessions.
479
+ segments: ['prd', `handoff-${rid}.md`],
480
+ legacySegments: ['prd', 'handoff.md'],
478
481
  supports: ['functional-completeness', 'existing-functionality-intact'],
479
482
  delivery: { kind: 'whole' }
480
483
  }
@@ -508,22 +511,43 @@ function classifyReadFailure(error) {
508
511
  function readEvidence(projectRoot, sessionId, rid, prePostDiffAvailable) {
509
512
  const runtimeRoot = join(projectRoot, '.peaks', '_runtime', sessionId);
510
513
  return evidenceSourcesFor(rid, prePostDiffAvailable).map(source => {
511
- const relativePath = ['.peaks', '_runtime', sessionId, ...source.segments].join('/');
512
- const absolutePath = join(runtimeRoot, ...source.segments);
514
+ // The canonical location first; an older one is tried only when the
515
+ // canonical file is genuinely ABSENT (see `legacySegments`). A file that
516
+ // is present but unreadable stops the walk — falling through to an older
517
+ // copy would silently swap the evidence this run reports.
518
+ const attempts = [
519
+ source.segments,
520
+ ...(source.legacySegments !== undefined ? [source.legacySegments] : [])
521
+ ];
522
+ // The path reported when nothing resolved stays the CANONICAL one, so the
523
+ // operator is sent to where the artifact belongs, not to its old home.
524
+ let resolved = source.segments;
513
525
  let raw = null;
514
526
  let error = '';
515
- let read = 'ok';
516
- try {
517
- raw = readFileSync(absolutePath);
518
- }
519
- catch (err) {
520
- read = classifyReadFailure(err);
521
- error = err instanceof Error ? err.message : String(err);
527
+ let read = 'missing';
528
+ for (const segments of attempts) {
529
+ try {
530
+ raw = readFileSync(join(runtimeRoot, ...segments));
531
+ read = 'ok';
532
+ resolved = segments;
533
+ break;
534
+ }
535
+ catch (err) {
536
+ read = classifyReadFailure(err);
537
+ error = err instanceof Error ? err.message : String(err);
538
+ // A file that exists but cannot be read IS this source's file, so it
539
+ // is also the path the report must name — walking on to an older copy
540
+ // would silently swap the evidence this run reports.
541
+ if (read === 'unreadable') {
542
+ resolved = segments;
543
+ break;
544
+ }
545
+ }
522
546
  }
523
547
  return {
524
548
  source,
525
- relativePath,
526
- absolutePath,
549
+ relativePath: ['.peaks', '_runtime', sessionId, ...resolved].join('/'),
550
+ absolutePath: join(runtimeRoot, ...resolved),
527
551
  raw,
528
552
  read,
529
553
  error,
@@ -15,6 +15,25 @@ export declare function getAdapter(ide: IdeId): IdeAdapter;
15
15
  export declare function tryGetAdapter(ide: string): IdeAdapter | undefined;
16
16
  /** All registered adapter ids (insertion order). */
17
17
  export declare function listAdapterIds(): readonly IdeId[];
18
+ /**
19
+ * Help text for every `--ide <id>` option, derived from the registry.
20
+ *
21
+ * It used to be a hand-written literal — `"target adapter id (claude-code |
22
+ * trae); default: auto-detect from env/cwd"` — copy-pasted into six option
23
+ * declarations across `hooks-commands.ts` and `statusline-commands.ts`. By the
24
+ * time anyone read it the registry had nine adapters, so the help named two of
25
+ * the nine values the option accepts and stayed silent about the other seven.
26
+ * A help string that enumerates a set it does not own cannot be kept true by
27
+ * discipline; deriving it here is the only form that cannot drift.
28
+ *
29
+ * Note for whoever is tempted to point the help at `peaks adapter list`
30
+ * instead: that command lists USER-REGISTERED vendor adapters from
31
+ * `.peaks/runtime/adapters.json` (a different registry, empty on a fresh
32
+ * project). It is not this set. The nine ids below have no CLI surface of
33
+ * their own; `peaks ide model --current` is the closest read-only viewer and
34
+ * it reports one adapter, not the list.
35
+ */
36
+ export declare function resolveIdeOptionHelp(): string;
18
37
  /** All registered adapters (insertion order). */
19
38
  export declare function listAdapters(): readonly IdeAdapter[];
20
39
  /**
@@ -57,6 +57,27 @@ export function tryGetAdapter(ide) {
57
57
  export function listAdapterIds() {
58
58
  return Array.from(ADAPTERS.keys());
59
59
  }
60
+ /**
61
+ * Help text for every `--ide <id>` option, derived from the registry.
62
+ *
63
+ * It used to be a hand-written literal — `"target adapter id (claude-code |
64
+ * trae); default: auto-detect from env/cwd"` — copy-pasted into six option
65
+ * declarations across `hooks-commands.ts` and `statusline-commands.ts`. By the
66
+ * time anyone read it the registry had nine adapters, so the help named two of
67
+ * the nine values the option accepts and stayed silent about the other seven.
68
+ * A help string that enumerates a set it does not own cannot be kept true by
69
+ * discipline; deriving it here is the only form that cannot drift.
70
+ *
71
+ * Note for whoever is tempted to point the help at `peaks adapter list`
72
+ * instead: that command lists USER-REGISTERED vendor adapters from
73
+ * `.peaks/runtime/adapters.json` (a different registry, empty on a fresh
74
+ * project). It is not this set. The nine ids below have no CLI surface of
75
+ * their own; `peaks ide model --current` is the closest read-only viewer and
76
+ * it reports one adapter, not the list.
77
+ */
78
+ export function resolveIdeOptionHelp() {
79
+ return `target adapter id (${listAdapterIds().join(' | ')}); default: auto-detect from env/cwd`;
80
+ }
60
81
  /** All registered adapters (insertion order). */
61
82
  export function listAdapters() {
62
83
  return Array.from(ADAPTERS.values());
@@ -22,6 +22,21 @@
22
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { z } from 'zod';
25
+ import { guardRuntimeSegment, runtimeRoot } from '../../shared/runtime-root.js';
26
+ /**
27
+ * The single place a job-progress directory is built, and therefore the single
28
+ * place the two ids that reach it are guarded.
29
+ *
30
+ * Slice 2026-09-15 (runtime-path-unrepresentable): this join used to be written
31
+ * at three sites, none of them guarded, and the shipped text rule could not see
32
+ * them — they sit in the service layer, outside the command layer it scans.
33
+ * Both ids are caller-supplied (`peaks job checkpoint` takes them from flags).
34
+ * The seam now requires `GuardedSegment`, so dropping either guard is a compile
35
+ * error rather than a finding someone has to notice.
36
+ */
37
+ function jobProgressDir(projectRoot, sessionId, jobId) {
38
+ return runtimeRoot(projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment('job', 'role'), guardRuntimeSegment(jobId, 'job id'));
39
+ }
25
40
  export const JOB_PROGRESS_SCHEMA_VERSION = 1;
26
41
  export const JobProgressSchema = z.object({
27
42
  schemaVersion: z.literal(JOB_PROGRESS_SCHEMA_VERSION),
@@ -33,7 +48,7 @@ export const JobProgressSchema = z.object({
33
48
  updatedAt: z.string().datetime()
34
49
  });
35
50
  export function writeJobProgress(projectRoot, sessionId, input) {
36
- const dir = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', input.jobId);
51
+ const dir = jobProgressDir(projectRoot, sessionId, input.jobId);
37
52
  mkdirSync(dir, { recursive: true });
38
53
  const record = {
39
54
  schemaVersion: JOB_PROGRESS_SCHEMA_VERSION,
@@ -49,7 +64,7 @@ export function writeJobProgress(projectRoot, sessionId, input) {
49
64
  return record;
50
65
  }
51
66
  export function readJobProgress(projectRoot, sessionId, jobId) {
52
- const path = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', jobId, 'progress.json');
67
+ const path = join(jobProgressDir(projectRoot, sessionId, jobId), 'progress.json');
53
68
  if (!existsSync(path)) {
54
69
  throw new Error(`JobProgressStore: no progress for ${jobId} at ${path}`);
55
70
  }
@@ -57,7 +72,7 @@ export function readJobProgress(projectRoot, sessionId, jobId) {
57
72
  return JobProgressSchema.parse(JSON.parse(raw));
58
73
  }
59
74
  export function tryReadJobProgress(projectRoot, sessionId, jobId) {
60
- const path = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', jobId, 'progress.json');
75
+ const path = join(jobProgressDir(projectRoot, sessionId, jobId), 'progress.json');
61
76
  if (!existsSync(path))
62
77
  return null;
63
78
  try {
@@ -1,12 +1,19 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { JobStateSchema } from './job-types.js';
4
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
4
5
  export class JobStateStore {
5
6
  rootDir;
6
7
  constructor(rootDir) {
7
8
  this.rootDir = rootDir;
8
9
  }
9
10
  jobDir(jobId) {
11
+ // JobId axis. This is the only join the store performs, so one guard here
12
+ // covers every `peaks job *` subcommand — ten CLI call sites hand the
13
+ // store a caller-supplied `--job-id` and none of them checked it.
14
+ if (isUnsafePathInput(jobId)) {
15
+ throw new Error(`Invalid job id: ${jobId} (must be a single path segment)`);
16
+ }
10
17
  return join(this.rootDir, jobId);
11
18
  }
12
19
  init(input) {
@@ -21,6 +21,19 @@ export declare const MAX_METRICS_FILES = 10;
21
21
  export declare function metricsFilePath(projectRoot: string, sessionId: string): string;
22
22
  /** Absolute path to a session's metrics directory. */
23
23
  export declare function metricsDirPath(projectRoot: string, sessionId: string): string;
24
+ /**
25
+ * Absolute path to a session's metrics JSONL file, or `null` when the
26
+ * session id names no session directory.
27
+ *
28
+ * Total sibling of `metricsFilePath`, built on the axis's own total
29
+ * entry (`tryGetSessionDir`) so the two agree on the predicate. It
30
+ * exists because this module has frames whose contract is never-throws
31
+ * (`readMetricLines`, and through it `readObservabilityEvents`) and
32
+ * `metricsFilePath` was the one call in them that could still throw.
33
+ * `null` here means exactly one thing — the id could not be resolved —
34
+ * and every caller below handles it in the same statement that reads it.
35
+ */
36
+ export declare function tryMetricsFilePath(projectRoot: string, sessionId: string): string | null;
24
37
  /**
25
38
  * Append one line to the session's metrics JSONL file. Creates the
26
39
  * directory tree on demand. Returns true on success, false on any
@@ -32,6 +45,12 @@ export declare function appendMetricLine(projectRoot: string, sessionId: string,
32
45
  * Returns [] when the file does not exist. Does NOT parse — callers
33
46
  * decide what counts as valid (see `observability-service.ts`
34
47
  * `readObservabilityEvents` for the schema-aware reader).
48
+ *
49
+ * Returns [] for an unresolvable session id too: this is the read half
50
+ * of a fire-and-forget store, and `readObservabilityEvents` documents
51
+ * that a session with no readable metrics file has no events. Throwing
52
+ * here used to escape as a raw guard error out of a documented
53
+ * never-throws reader (repair R6).
35
54
  */
36
55
  export declare function readMetricLines(projectRoot: string, sessionId: string): string[];
37
56
  export type SessionMetricsEntry = {
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { getSessionDir } from '../session/getSessionDir.js';
19
+ import { getSessionDir, tryGetSessionDir } from '../session/getSessionDir.js';
20
20
  export const METRICS_DIR = 'metrics';
21
21
  export const METRICS_FILENAME = 'slices.jsonl';
22
22
  export const MAX_METRICS_FILES = 10;
@@ -28,6 +28,22 @@ export function metricsFilePath(projectRoot, sessionId) {
28
28
  export function metricsDirPath(projectRoot, sessionId) {
29
29
  return join(getSessionDir(projectRoot, sessionId), METRICS_DIR);
30
30
  }
31
+ /**
32
+ * Absolute path to a session's metrics JSONL file, or `null` when the
33
+ * session id names no session directory.
34
+ *
35
+ * Total sibling of `metricsFilePath`, built on the axis's own total
36
+ * entry (`tryGetSessionDir`) so the two agree on the predicate. It
37
+ * exists because this module has frames whose contract is never-throws
38
+ * (`readMetricLines`, and through it `readObservabilityEvents`) and
39
+ * `metricsFilePath` was the one call in them that could still throw.
40
+ * `null` here means exactly one thing — the id could not be resolved —
41
+ * and every caller below handles it in the same statement that reads it.
42
+ */
43
+ export function tryMetricsFilePath(projectRoot, sessionId) {
44
+ const resolved = tryGetSessionDir(projectRoot, sessionId);
45
+ return resolved.ok ? join(resolved.dir, METRICS_DIR, METRICS_FILENAME) : null;
46
+ }
31
47
  /**
32
48
  * Append one line to the session's metrics JSONL file. Creates the
33
49
  * directory tree on demand. Returns true on success, false on any
@@ -51,9 +67,18 @@ export function appendMetricLine(projectRoot, sessionId, line) {
51
67
  * Returns [] when the file does not exist. Does NOT parse — callers
52
68
  * decide what counts as valid (see `observability-service.ts`
53
69
  * `readObservabilityEvents` for the schema-aware reader).
70
+ *
71
+ * Returns [] for an unresolvable session id too: this is the read half
72
+ * of a fire-and-forget store, and `readObservabilityEvents` documents
73
+ * that a session with no readable metrics file has no events. Throwing
74
+ * here used to escape as a raw guard error out of a documented
75
+ * never-throws reader (repair R6).
54
76
  */
55
77
  export function readMetricLines(projectRoot, sessionId) {
56
- const path = metricsFilePath(projectRoot, sessionId);
78
+ const path = tryMetricsFilePath(projectRoot, sessionId);
79
+ if (path === null) {
80
+ return [];
81
+ }
57
82
  if (!existsSync(path)) {
58
83
  return [];
59
84
  }
@@ -56,11 +56,15 @@ export type EmitOptions = {
56
56
  /** Absolute path to the project root (where `.peaks/_runtime/` lives). */
57
57
  projectRoot: string;
58
58
  };
59
- export type EmitFailureReason = 'invalid-schema' | 'write-failed';
59
+ export type EmitFailureReason = 'invalid-schema' | 'write-failed' | 'invalid-session-id';
60
60
  export type EmitResult = {
61
61
  /** True when the JSONL line was appended; false on any error path. */
62
62
  written: boolean;
63
- /** Absolute path to the metrics file the event was written to (or would be). */
63
+ /**
64
+ * Absolute path to the metrics file the event was written to (or would
65
+ * be). Empty string when the session id named no session directory —
66
+ * there is no path to report, and `reason` says so.
67
+ */
64
68
  path: string;
65
69
  /** Set only when `written` is false. */
66
70
  reason?: EmitFailureReason;
@@ -81,7 +85,10 @@ export declare function emitObservabilityEvent(event: ObservabilityEvent, option
81
85
  * lines and any record whose `schemaVersion` does not match the
82
86
  * current `OBSERVABILITY_SCHEMA_VERSION` (forward-compat per Q3).
83
87
  *
84
- * Returns [] when the session has no metrics file yet.
88
+ * Returns [] when the session has no metrics file yet, and [] when the
89
+ * session id names no session directory — both are "no events are
90
+ * readable here", and this reader does not throw (repair R6: it used to,
91
+ * via `readMetricLines` → `metricsFilePath`).
85
92
  */
86
93
  export declare function readObservabilityEvents(projectRoot: string, sessionId: string): ObservabilityEvent[];
87
94
  /**