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
@@ -7,22 +7,34 @@
7
7
  * gates plus the verify-pipeline, so the orchestrator no longer hand-writes
8
8
  * them per mechanical file-split (or similar) slice.
9
9
  *
10
- * The content markers emitted here are the exact strings the CLI gates +
11
- * verify-pipeline mechanically check — see
12
- * `src/services/artifacts/artifact-prerequisites.ts` (mustContain /
13
- * mustContainAny / headingMustContain) and
14
- * `src/services/workflow/pipeline-verify-gate-support.ts` +
15
- * `src/services/workflow/artifact-paths.ts` (suffixed security/performance
16
- * findings). Do NOT reword the marker lines.
10
+ * The content markers emitted here are the exact strings the CLI gates
11
+ * mechanically check — see `src/services/artifacts/artifact-prerequisites.ts`
12
+ * (mustContain / mustContainAny / headingMustContain — the authoritative
13
+ * table). `peaks workflow verify-pipeline` reads that same table for both the
14
+ * evidence paths it probes and the markers it enforces
15
+ * (`src/services/workflow/pipeline-verify-gate-support.ts#contractEvidencePaths`).
16
+ * Do NOT reword the marker lines.
17
+ *
18
+ * This paragraph used to send editors to `src/services/workflow/artifact-paths.ts`
19
+ * as the checker of "suffixed security/performance findings". That is no longer
20
+ * true: the `security-findings-<rid>.md` / `performance-findings-<rid>.md` gates
21
+ * were removed in rid `2026-09-14-verify-pipeline-contract-drift` (no
22
+ * `qa:verdict-issued` table names those paths; security and perf evidence is
23
+ * resolved on the RD side at `audit/security-<rid>.md` / `audit/perf-<rid>.md`),
24
+ * and `artifact-paths.ts` has had zero code consumers since. It is not a marker
25
+ * authority, so it is not a pointer worth keeping.
17
26
  *
18
27
  * Karpathy §2 (Simplicity First): a single generator function + a handful of
19
28
  * pure body builders. No speculative options, no validation beyond what the
20
29
  * reference prototype performs.
21
30
  */
22
- import { createHash } from 'node:crypto';
23
31
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
24
32
  import { join } from 'node:path';
33
+ import { serializeHandoffFrontmatter } from '../prd/handoff-frontmatter.js';
34
+ import { handoffRelativePath, sha256OfBody } from '../prd/handoff-service.js';
25
35
  import { getSessionDir } from '../session/getSessionDir.js';
36
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
37
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
26
38
  /** Comma/space tolerant `--files` splitter. */
27
39
  export function parseFiles(raw) {
28
40
  return raw
@@ -235,40 +247,20 @@ function buildQaRequest(rid, sid, files) {
235
247
  `;
236
248
  }
237
249
  /**
238
- * Build the `prd/handoff.md` frontmatter + body and compute the sha256
239
- * fingerprint. The hash is computed over the frontmatter (with the
240
- * `handoffHash` line left empty — `sha256:`) concatenated with the body,
241
- * matching the reference prototype exactly.
250
+ * Build the `prd/handoff-<rid>.md` frontmatter + body. The frontmatter comes from the
251
+ * ONE canonical serializer and `handoffHash === sha256(body)` — the same
252
+ * pairing `handoff-service.initHandoff` and `handoff-auto-regen.ts` use, so
253
+ * this producer cannot drift from them.
254
+ *
255
+ * This function used to hand-roll a third, divergent frontmatter: a
256
+ * `handoffHash: sha256:<hex>` value with **no line beginning `sha256:`**, over
257
+ * a hash computed on `frontmatter + body` rather than the body. The
258
+ * `AUDIT_REQUIRES_HANDOFF` gate (a substring check) accepted that file while
259
+ * `readAndVerifyHandoff` in both audit skills returned `null` — the very
260
+ * producer/consumer divergence rid `2026-09-14-handoff-writer-gate-divergence`
261
+ * exists to remove, surviving in a function the same slice edited.
242
262
  */
243
263
  function buildHandoff(rid, sid, title, files, lineCounts) {
244
- const bulletList = files.map((f) => ` - ${f}`).join('\n');
245
- const frontmatter = `---
246
- requestId: ${rid}
247
- scope:
248
- ${bulletList}
249
- files:
250
- ${bulletList}
251
- handoffPath: .peaks/_runtime/${sid}/prd/handoff.md
252
- handoffHash: sha256:PLACEHOLDER
253
- decisions:
254
- - id: D1
255
- summary: "Mechanical verbatim module split"
256
- rationale: "Satisfy the 800-line file-size gate."
257
- risks:
258
- - id: R1
259
- description: "Public exports may be imported elsewhere"
260
- mitigation: "Import sites updated; tsc clean."
261
- nextActions:
262
- - "peaks-qa validates the regression matrix"
263
- gateEvidence:
264
- projectScan: .peaks/project-scan/project-scan.md
265
- prdHandoff: .peaks/_runtime/${sid}/prd/handoff.md
266
- codeReview: .peaks/_runtime/${sid}/rd/code-review.md
267
- securityReview: .peaks/_runtime/${sid}/rd/security-review.md
268
- perfBaseline: .peaks/_runtime/${sid}/audit/perf.md
269
- schemaVersion: 2
270
- ---
271
- `;
272
264
  const body = `# PRD Handoff — ${rid}
273
265
 
274
266
  ${title}. Mechanical verbatim module split; behavior-preserving.
@@ -279,10 +271,19 @@ ${filesMd(files)}
279
271
  Line counts:
280
272
  ${lineCountsMd(lineCounts)}
281
273
  `;
282
- const hashInput = frontmatter.replace('sha256:PLACEHOLDER', 'sha256:') + body;
283
- const hash = createHash('sha256').update(hashInput, 'utf8').digest('hex');
284
- const content = frontmatter.replace('sha256:PLACEHOLDER', `sha256:${hash}`) + body;
285
- return { content, hash };
274
+ const handoffHash = sha256OfBody(body);
275
+ const frontmatter = {
276
+ requestId: rid,
277
+ sessionId: sid,
278
+ schemaVersion: '2',
279
+ handoffHash,
280
+ writtenAt: new Date().toISOString(),
281
+ goals: [],
282
+ acceptanceCriteria: [],
283
+ preservedBehavior: [],
284
+ handoffPath: handoffRelativePath(sid, rid)
285
+ };
286
+ return { content: `${serializeHandoffFrontmatter(frontmatter)}${body}`, hash: handoffHash };
286
287
  }
287
288
  /** Find the existing numbered QA request file for `rid`, else the default. */
288
289
  async function resolveQaRequestPath(qaDir, rid) {
@@ -300,6 +301,21 @@ async function resolveQaRequestPath(qaDir, rid) {
300
301
  }
301
302
  export async function generateEvidence(options) {
302
303
  const { projectRoot, rid, title, files, lineCounts, sessionId } = options;
304
+ // Both values become path segments below — the rid as a filename, the sid as
305
+ // the session directory. Guard them BEFORE the first mkdir so a rejected run
306
+ // leaves nothing behind. `REQUEST_ID_PATTERN` is the repo's own request-id
307
+ // control (`request-artifact-service.ts`, F-1 slice 025 security); the sid
308
+ // gets the segment check because `--session-id` has no pinned format.
309
+ // Measured before these lines existed: `--rid '../../../pwned'` wrote
310
+ // `.peaks/_runtime/pwned.md`, outside the per-session evidence dir, and a
311
+ // deeper rid wrote above the project root with the string echoed into the
312
+ // artifact body.
313
+ if (!REQUEST_ID_PATTERN.test(rid)) {
314
+ throw new Error(`Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`);
315
+ }
316
+ if (isUnsafePathInput(sessionId)) {
317
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
318
+ }
303
319
  const sessionRoot = getSessionDir(projectRoot, sessionId);
304
320
  const rdDir = join(sessionRoot, 'rd');
305
321
  const qaDir = join(sessionRoot, 'qa');
@@ -319,18 +335,39 @@ export async function generateEvidence(options) {
319
335
  }
320
336
  const qaRequestPath = await resolveQaRequestPath(qaDir, rid);
321
337
  const handoff = buildHandoff(rid, sessionId, title, files, lineCounts);
338
+ // Four of these twelve paths carry the rid because the `rd:qa-handoff` gate
339
+ // requires `<rid>` in them: every slice in a session shares `rd/` and
340
+ // `audit/`, so writing the ridless name silently destroyed the previous
341
+ // slice's evidence on 2026-09-13 while the gate stayed green (slice
342
+ // `2026-09-14-audit-artifact-rid-scoping`).
322
343
  const writes = [
323
- [join(rdDir, 'code-review.md'), buildCodeReview(rid, title, files, lineCounts)],
344
+ [join(rdDir, `code-review-${rid}.md`), buildCodeReview(rid, title, files, lineCounts)],
345
+ [join(auditDir, `security-${rid}.md`), buildSecurityReview(rid)],
346
+ // ...but the `config` type is the one whose `rd:qa-handoff` row is
347
+ // `SECURITY_REVIEW` — the genuinely ridless `rd/security-review.md` — and
348
+ // this generator is request-type-agnostic (no `--request-type`). So it
349
+ // writes BOTH names: the rid-scoped one the fanout types resolve
350
+ // (`AUDIT_SECURITY`) and the bare one `config` resolves. Nothing is lost
351
+ // by having both; a `config` slice has nothing else to fall back on, and
352
+ // dropping this write made a `config` slice fail its own gate while the
353
+ // generator reported success (repair round: measured `missing:
354
+ // ['rd/security-review.md']` with the real generator and the real gate).
324
355
  [join(rdDir, 'security-review.md'), buildSecurityReview(rid)],
325
- [join(rdDir, 'karpathy-review.md'), buildKarpathyReview(rid, lineCounts)],
356
+ [join(rdDir, `karpathy-review-${rid}.md`), buildKarpathyReview(rid, lineCounts)],
357
+ // `rd/tech-doc.md` stays ridless on purpose: the TECH_DOC prereq was
358
+ // removed in v2.11.0, so nothing gates it and there is no rid-scoped
359
+ // sibling that anything reads.
326
360
  [join(rdDir, 'tech-doc.md'), buildTechDoc(rid, title, files, lineCounts)],
327
- [join(auditDir, 'perf.md'), buildPerfAudit(rid)],
361
+ [join(auditDir, `perf-${rid}.md`), buildPerfAudit(rid)],
328
362
  [join(qaDir, 'test-cases', `${rid}.md`), buildTestCases(rid)],
329
363
  [join(qaDir, 'test-reports', `${rid}.md`), buildTestReport(rid)],
330
364
  [join(qaDir, `security-findings-${rid}.md`), buildSecurityFindings(rid)],
331
365
  [join(qaDir, `performance-findings-${rid}.md`), buildPerformanceFindings(rid)],
332
366
  [qaRequestPath, buildQaRequest(rid, sessionId, files)],
333
- [join(prdDir, 'handoff.md'), handoff.content]
367
+ // The handoff capsule carries the rid for the same reason as the four
368
+ // above (slice `2026-09-14-prd-capsule-rid-scoping`): one slot per
369
+ // session means the second slice's capsule overwrites the first's.
370
+ [join(prdDir, `handoff-${rid}.md`), handoff.content]
334
371
  ];
335
372
  const writtenFiles = [];
336
373
  for (const [path, content] of writes) {
@@ -341,7 +378,7 @@ export async function generateEvidence(options) {
341
378
  rid,
342
379
  sessionId,
343
380
  sessionRoot,
344
- handoffPath: join(prdDir, 'handoff.md'),
381
+ handoffPath: join(prdDir, `handoff-${rid}.md`),
345
382
  handoffHash: handoff.hash,
346
383
  writtenFiles,
347
384
  createdDirectories
@@ -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());
@@ -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) {
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, join } from 'node:path';
28
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
28
29
  /** Compute the child-side path where the artifact should be mirrored.
29
30
  * Mirrors the parent shape exactly: `.peaks/_runtime/<sid>/<role>/`. */
30
31
  function childArtifactPath(childRoot, sid, role, sourcePath) {
@@ -37,6 +38,16 @@ function ensureDirFor(file) {
37
38
  export function dispatchArtifact(opts) {
38
39
  const warnings = [];
39
40
  const perChild = [];
41
+ // `sid` becomes a path segment in every child (`childArtifactPath`). It has no
42
+ // pinned format (`--sid` is a free-form override), so it gets the repo's
43
+ // segment check — the same `isUnsafePathInput` `peaks evidence generate` and
44
+ // `peaks verdict aggregate` apply to their session id. Guard before the first
45
+ // mkdir so a rejected dispatch leaves nothing behind. Measured before this
46
+ // line existed: `--sid '../../../../../../PWNED-SID-ESCAPE'` wrote
47
+ // `<parent-of-fixture>/PWNED-SID-ESCAPE/prd/src.md`, above every project root.
48
+ if (isUnsafePathInput(opts.sid)) {
49
+ throw new Error(`Invalid session id: ${opts.sid} (must be a single path segment)`);
50
+ }
40
51
  // Validate targets against the manifest.
41
52
  const knownIds = new Set(opts.manifest.children.map((c) => c.id));
42
53
  const validTargets = [];
@@ -1,13 +1,18 @@
1
1
  /**
2
- * v2.13.2 AC-4 — prd/handoff.md auto-regen on prd:handed-off.
2
+ * v2.13.2 AC-4 — prd/handoff-<rid>.md auto-regen on prd:handed-off.
3
3
  *
4
4
  * When `peaks request transition --role prd --state handed-off` succeeds
5
- * and `prd/handoff.md` is missing, this helper writes a sha256-locked
5
+ * and this slice's capsule is missing, this helper writes a sha256-locked
6
6
  * handoff (schemaVersion: 2) using the request artifact body as the
7
- * handoff body. If the handoff already exists, it's NOT overwritten —
7
+ * handoff body. If the capsule already exists, it's NOT overwritten —
8
8
  * the existing handoff is canonical (it may carry a richer body that
9
9
  * peaks-prd produced in an earlier session).
10
10
  *
11
+ * Slice `2026-09-14-prd-capsule-rid-scoping`: the path carries the rid, so
12
+ * "already exists" is now asked per SLICE. The pre-rid-scoping bare name is
13
+ * deliberately NOT consulted — writing there would recreate the
14
+ * one-slot-per-session collision for the next slice in the session.
15
+ *
11
16
  * Karpathy §3 (Surgical Changes): this file only owns the auto-regen
12
17
  * path. The other 11 transitions are untouched.
13
18
  */
@@ -15,7 +20,8 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
15
20
  import { dirname, join } from 'node:path';
16
21
  import { createHash } from 'node:crypto';
17
22
  import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
18
- import { sha256OfBody } from './handoff-service.js';
23
+ import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
24
+ import { handoffRelativePath, sha256OfBody } from './handoff-service.js';
19
25
  import { normalizePath } from '../../shared/path-utils.js';
20
26
  /**
21
27
  * Auto-regen the prd/handoff.md under `.peaks/_runtime/<sid>/prd/`.
@@ -25,7 +31,7 @@ export async function autoRegenPrdHandoff(opts) {
25
31
  if (opts.role !== 'prd') {
26
32
  return { status: 'failed', reason: 'role must be prd' };
27
33
  }
28
- const handoffPath = join(opts.projectRoot, '.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md');
34
+ const handoffPath = join(opts.projectRoot, handoffRelativePath(opts.sessionId, opts.requestId));
29
35
  if (existsSync(handoffPath)) {
30
36
  return { status: 'skipped-exists', path: handoffPath };
31
37
  }
@@ -41,28 +47,26 @@ export async function autoRegenPrdHandoff(opts) {
41
47
  const body = artifact.content;
42
48
  const sha256 = sha256OfBody(body);
43
49
  // v2.13.3 AC-4 — align with `AUDIT_REQUIRES_HANDOFF` prereq which
44
- // pins `mustContain: ['schemaVersion: 2', 'sha256:']`. The previous
45
- // field name `handoffHash` made peaks-loop write a handoff that the
46
- // own prereq resolver would reject with "missing section(s): sha256:".
47
- // Primary field is now `sha256`; `handoffHash` is kept as a literal
48
- // alias for back-compat with any consumer (UI / external scripts)
49
- // that still reads the old key.
50
- const frontmatter = [
51
- '---',
52
- `requestId: ${opts.requestId}`,
53
- `sessionId: ${opts.sessionId}`,
54
- 'schemaVersion: 2',
55
- `sha256: ${sha256}`,
56
- `handoffHash: ${sha256}`,
57
- `writtenAt: ${new Date().toISOString()}`,
58
- 'goals: []',
59
- 'acceptanceCriteria: []',
60
- 'preservedBehavior: []',
61
- `handoffPath: ${normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')}`,
62
- '---',
63
- ''
64
- ].join('\n');
65
- const content = `${frontmatter}${body}`;
50
+ // pins `mustContain: ['schemaVersion: 2', 'sha256:']`. Primary field is
51
+ // `sha256`; `handoffHash` is kept as a literal alias for the readers that
52
+ // still use the old key.
53
+ //
54
+ // Slice `2026-09-14-handoff-writer-gate-divergence`: this block used to be
55
+ // hand-rolled here while `handoff-service.serializeHandoff` rendered the
56
+ // SAME contract a second, incompatible way. Both now go through
57
+ // `serializeHandoffFrontmatter`, so the two producers cannot drift again.
58
+ const frontmatter = {
59
+ requestId: opts.requestId,
60
+ sessionId: opts.sessionId,
61
+ schemaVersion: '2',
62
+ handoffHash: sha256,
63
+ writtenAt: new Date().toISOString(),
64
+ goals: [],
65
+ acceptanceCriteria: [],
66
+ preservedBehavior: [],
67
+ handoffPath: normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')
68
+ };
69
+ const content = `${serializeHandoffFrontmatter(frontmatter)}${body}`;
66
70
  mkdirSync(dirname(handoffPath), { recursive: true });
67
71
  writeFileSync(handoffPath, content, 'utf8');
68
72
  const recomputed = createHash('sha256').update(body, 'utf8').digest('hex');
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The ONE canonical serialization of `prd/handoff.md` frontmatter.
3
+ *
4
+ * Why this module exists (slice `2026-09-14-handoff-writer-gate-divergence`):
5
+ * the handoff capsule is written by two producers and read by four
6
+ * consumers, and they did not agree on the bytes:
7
+ *
8
+ * | consumer / producer | requirement |
9
+ * |---|---|
10
+ * | `AUDIT_REQUIRES_HANDOFF` (`artifact-prerequisites.ts`) | SUBSTRING `schemaVersion: 2` (unquoted) + `sha256:` |
11
+ * | `audit-independent/{security,perf}-audit-service.ts` | anchored `^schemaVersion:\s*(\d+)\s*$` and `^sha256:\s*([a-f0-9]{64})\s*$` |
12
+ * | `handoff-service.readHandoff` | YAML string `handoffHash` |
13
+ * | `handoff-service.verifyHandoff` | `handoffHash` === sha256(body), bare hex |
14
+ *
15
+ * `handoff-service.serializeHandoff` fed the frontmatter through
16
+ * `yaml.stringify`, which emits `schemaVersion: "2"` and a bare
17
+ * `handoffHash:` — failing the gate AND both loaders. `handoff-auto-regen.ts`
18
+ * hand-rolled a second serialization that happened to satisfy all four. Two
19
+ * producers, two facts about the same contract, one of them wrong: that is
20
+ * the defect, and copying the right shape into a third place would keep it.
21
+ *
22
+ * So both producers call THIS function. `sha256` is emitted only here, and
23
+ * it is emitted plain, because the two audit loaders anchor a regex on it.
24
+ *
25
+ * Scalar-quoting rule — one rule, one documented exception set:
26
+ * - every ordinary string is emitted through `yamlScalar` (a JSON
27
+ * double-quoted scalar, which is valid YAML 1.2 and round-trips
28
+ * backslashes correctly on Windows paths);
29
+ * - `schemaVersion` and `sha256` are the exception: emitted PLAIN, because
30
+ * they are the two scalars the gate and the loaders match textually and a
31
+ * quoted scalar is invisible to all three. Both values are structurally
32
+ * constrained (a version digit and 64 hex chars), so plain is unambiguous.
33
+ *
34
+ * `handoffHash` is quoted even though it is also a 64-hex value: the reader
35
+ * parses the block through YAML and requires a STRING, and a bare all-digit
36
+ * sha256 would parse as a YAML number and be refused by the shape check.
37
+ */
38
+ import type { HandoffFrontmatter } from './handoff-types.js';
39
+ /**
40
+ * Serialize `frontmatter` into the fenced block, terminated by the closing
41
+ * `---` and a trailing newline. Callers append the body verbatim, which keeps
42
+ * the sha256 of the body independent of how the frontmatter renders.
43
+ */
44
+ export declare function serializeHandoffFrontmatter(frontmatter: HandoffFrontmatter): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The ONE canonical serialization of `prd/handoff.md` frontmatter.
3
+ *
4
+ * Why this module exists (slice `2026-09-14-handoff-writer-gate-divergence`):
5
+ * the handoff capsule is written by two producers and read by four
6
+ * consumers, and they did not agree on the bytes:
7
+ *
8
+ * | consumer / producer | requirement |
9
+ * |---|---|
10
+ * | `AUDIT_REQUIRES_HANDOFF` (`artifact-prerequisites.ts`) | SUBSTRING `schemaVersion: 2` (unquoted) + `sha256:` |
11
+ * | `audit-independent/{security,perf}-audit-service.ts` | anchored `^schemaVersion:\s*(\d+)\s*$` and `^sha256:\s*([a-f0-9]{64})\s*$` |
12
+ * | `handoff-service.readHandoff` | YAML string `handoffHash` |
13
+ * | `handoff-service.verifyHandoff` | `handoffHash` === sha256(body), bare hex |
14
+ *
15
+ * `handoff-service.serializeHandoff` fed the frontmatter through
16
+ * `yaml.stringify`, which emits `schemaVersion: "2"` and a bare
17
+ * `handoffHash:` — failing the gate AND both loaders. `handoff-auto-regen.ts`
18
+ * hand-rolled a second serialization that happened to satisfy all four. Two
19
+ * producers, two facts about the same contract, one of them wrong: that is
20
+ * the defect, and copying the right shape into a third place would keep it.
21
+ *
22
+ * So both producers call THIS function. `sha256` is emitted only here, and
23
+ * it is emitted plain, because the two audit loaders anchor a regex on it.
24
+ *
25
+ * Scalar-quoting rule — one rule, one documented exception set:
26
+ * - every ordinary string is emitted through `yamlScalar` (a JSON
27
+ * double-quoted scalar, which is valid YAML 1.2 and round-trips
28
+ * backslashes correctly on Windows paths);
29
+ * - `schemaVersion` and `sha256` are the exception: emitted PLAIN, because
30
+ * they are the two scalars the gate and the loaders match textually and a
31
+ * quoted scalar is invisible to all three. Both values are structurally
32
+ * constrained (a version digit and 64 hex chars), so plain is unambiguous.
33
+ *
34
+ * `handoffHash` is quoted even though it is also a 64-hex value: the reader
35
+ * parses the block through YAML and requires a STRING, and a bare all-digit
36
+ * sha256 would parse as a YAML number and be refused by the shape check.
37
+ */
38
+ /** Render a string as a YAML double-quoted scalar. JSON string escapes are a
39
+ * subset of YAML 1.2's double-quoted escapes, so this is valid YAML and
40
+ * handles `\` (Windows paths), quotes, colons and newlines in one step. */
41
+ function yamlScalar(value) {
42
+ return JSON.stringify(value);
43
+ }
44
+ /** Render `key: []` or a YAML block sequence of quoted scalars. */
45
+ function blockSequence(key, values) {
46
+ if (values.length === 0)
47
+ return [`${key}: []`];
48
+ return [`${key}:`, ...values.map((value) => ` - ${yamlScalar(value)}`)];
49
+ }
50
+ /**
51
+ * Serialize `frontmatter` into the fenced block, terminated by the closing
52
+ * `---` and a trailing newline. Callers append the body verbatim, which keeps
53
+ * the sha256 of the body independent of how the frontmatter renders.
54
+ */
55
+ export function serializeHandoffFrontmatter(frontmatter) {
56
+ const lines = [
57
+ '---',
58
+ `requestId: ${yamlScalar(frontmatter.requestId)}`,
59
+ `sessionId: ${yamlScalar(frontmatter.sessionId)}`,
60
+ // PLAIN by construction (`HandoffSchemaVersion` is the literal '2').
61
+ `schemaVersion: ${frontmatter.schemaVersion}`,
62
+ // PLAIN: this is the field both audit loaders read, via an anchored
63
+ // regex that a quoted scalar would not match.
64
+ `sha256: ${frontmatter.handoffHash}`,
65
+ // Quoted: `readHandoff` requires a YAML string here.
66
+ `handoffHash: ${yamlScalar(frontmatter.handoffHash)}`,
67
+ `writtenAt: ${yamlScalar(frontmatter.writtenAt)}`,
68
+ ...blockSequence('goals', frontmatter.goals),
69
+ ...blockSequence('acceptanceCriteria', frontmatter.acceptanceCriteria),
70
+ ...blockSequence('preservedBehavior', frontmatter.preservedBehavior),
71
+ `handoffPath: ${yamlScalar(frontmatter.handoffPath)}`,
72
+ '---',
73
+ ];
74
+ return `${lines.join('\n')}\n`;
75
+ }