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
@@ -19,7 +19,7 @@
19
19
  * result (fire-and-forget by convention).
20
20
  */
21
21
  import { z } from 'zod';
22
- import { appendMetricLine, metricsFilePath, pruneMetricsFiles, readMetricLines } from './jsonl-store.js';
22
+ import { appendMetricLine, pruneMetricsFiles, readMetricLines, tryMetricsFilePath } from './jsonl-store.js';
23
23
  export const OBSERVABILITY_SCHEMA_VERSION = 1;
24
24
  export const OBSERVABILITY_CATEGORIES = [
25
25
  'slice-transition',
@@ -74,7 +74,17 @@ export const ObservabilityEventSchema = z.object({
74
74
  * session count is below `MAX_METRICS_FILES`.
75
75
  */
76
76
  export function emitObservabilityEvent(event, options) {
77
- const path = metricsFilePath(options.projectRoot, event.sessionId);
77
+ // The session id is resolved through the axis's TOTAL entry, before anything
78
+ // else. The contract two doc comments above is that this function never
79
+ // throws; the previous first line called the axis's THROWING entry, so an
80
+ // unsafe session id made it throw — measured (repair R4) as
81
+ // `Invalid session id: ../../../../RD-R4-PWNED`, against a legal control
82
+ // that returned `written: true`. A guard refusal is a failure like any
83
+ // other here: it becomes a reason, not an exception.
84
+ const path = tryMetricsFilePath(options.projectRoot, event.sessionId);
85
+ if (path === null) {
86
+ return { written: false, path: '', reason: 'invalid-session-id' };
87
+ }
78
88
  const validation = ObservabilityEventSchema.safeParse(event);
79
89
  if (!validation.success) {
80
90
  return { written: false, path, reason: 'invalid-schema' };
@@ -93,7 +103,10 @@ export function emitObservabilityEvent(event, options) {
93
103
  * lines and any record whose `schemaVersion` does not match the
94
104
  * current `OBSERVABILITY_SCHEMA_VERSION` (forward-compat per Q3).
95
105
  *
96
- * Returns [] when the session has no metrics file yet.
106
+ * Returns [] when the session has no metrics file yet, and [] when the
107
+ * session id names no session directory — both are "no events are
108
+ * readable here", and this reader does not throw (repair R6: it used to,
109
+ * via `readMetricLines` → `metricsFilePath`).
97
110
  */
98
111
  export function readObservabilityEvents(projectRoot, sessionId) {
99
112
  const lines = readMetricLines(projectRoot, sessionId);
@@ -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
+ }
@@ -3,7 +3,9 @@
3
3
  * `v2-11-rm-rd-techdoc-immutable-handoff`).
4
4
  *
5
5
  * Owns the immutable handoff at
6
- * `.peaks/_runtime/<sessionId>/prd/handoff.md`:
6
+ * `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
7
+ * the pre-rid-scoping `.peaks/_runtime/<sessionId>/prd/handoff.md` stays
8
+ * readable through `resolveHandoffPath`):
7
9
  *
8
10
  * - `initHandoff` — pure; computes sha256 of the body and returns
9
11
  * a Handoff whose frontmatter `handoffHash` matches.
@@ -23,6 +25,43 @@
23
25
  import type { Handoff, HandoffProbe } from './handoff-types.js';
24
26
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
25
27
  export declare function sha256OfBody(body: string): string;
28
+ /**
29
+ * The canonical capsule path for ONE SLICE, relative to the project root.
30
+ *
31
+ * Slice `2026-09-14-prd-capsule-rid-scoping`: the capsule used to be one slot
32
+ * per SESSION (`prd/handoff.md`), so the second slice's handoff silently
33
+ * overwrote the first slice's — and `AUDIT_REQUIRES_HANDOFF` stayed green
34
+ * because it never checked WHOSE rid the file named. Measured on
35
+ * `2026-09-13-session-21878f`: a four-slice job passed that prerequisite on a
36
+ * capsule left by a different line of work.
37
+ *
38
+ * This is the WRITE target, so it always carries the rid — a consumer-side
39
+ * fallback here would leave a rid-scoped requirement with a bare-name writer,
40
+ * which is the defect shape this slice exists to remove. Readers that must
41
+ * tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
42
+ */
43
+ export declare function handoffRelativePath(sessionId: string, requestId: string): string;
44
+ /**
45
+ * The capsule a CONSUMER should read for (session, requestId): the rid-scoped
46
+ * path when it is on disk, else the pre-rid-scoping bare name. Returns null
47
+ * when neither exists.
48
+ *
49
+ * Three sessions on disk still hold only the bare file
50
+ * (`2026-09-06-session-a87ca4`, `2026-09-12-session-e37ef0`,
51
+ * `2026-09-13-session-21878f`), so the legacy tier has to keep resolving for
52
+ * the gate and for every reader below.
53
+ *
54
+ * `requestId` is optional because the detect-only audit surface reaches its
55
+ * service without one. Such a caller can name only the bare path: a session
56
+ * holding nothing but rid-scoped capsules reports missing rather than picking
57
+ * among its siblings' capsules, which would re-open the cross-slice mix-up
58
+ * this scoping exists to close (fail closed, not "some capsule is there").
59
+ */
60
+ export declare function resolveHandoffPath(opts: {
61
+ projectRoot: string;
62
+ sessionId: string;
63
+ requestId?: string;
64
+ }): string | null;
26
65
  /** Pure: produce a Handoff with the frontmatter populated. Hash is
27
66
  * computed here; callers MUST NOT pre-populate `handoffHash`. */
28
67
  export declare function initHandoff(opts: {
@@ -33,7 +72,7 @@ export declare function initHandoff(opts: {
33
72
  goals: readonly string[];
34
73
  acceptanceCriteria: readonly string[];
35
74
  preservedBehavior: readonly string[];
36
- /** Override path; defaults to `.peaks/_runtime/<sid>/prd/handoff.md`. */
75
+ /** Override path; defaults to `.peaks/_runtime/<sid>/prd/handoff-<rid>.md`. */
37
76
  handoffPath?: string;
38
77
  }): Handoff;
39
78
  /** Write a Handoff to disk. `projectRoot` is the absolute project
@@ -3,7 +3,9 @@
3
3
  * `v2-11-rm-rd-techdoc-immutable-handoff`).
4
4
  *
5
5
  * Owns the immutable handoff at
6
- * `.peaks/_runtime/<sessionId>/prd/handoff.md`:
6
+ * `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
7
+ * the pre-rid-scoping `.peaks/_runtime/<sessionId>/prd/handoff.md` stays
8
+ * readable through `resolveHandoffPath`):
7
9
  *
8
10
  * - `initHandoff` — pure; computes sha256 of the body and returns
9
11
  * a Handoff whose frontmatter `handoffHash` matches.
@@ -21,20 +23,111 @@
21
23
  * markdown source — no normalization, no trailing-newline padding.
22
24
  */
23
25
  import { createHash } from 'node:crypto';
26
+ import { existsSync } from 'node:fs';
24
27
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
28
  import { dirname, join } from 'node:path';
26
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
29
+ import { parse as parseYaml } from 'yaml';
30
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
31
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
32
+ import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
27
33
  /** Required schema version for new handoffs. */
28
34
  const HANDOFF_SCHEMA_VERSION = '2';
29
35
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
30
36
  export function sha256OfBody(body) {
31
37
  return createHash('sha256').update(body, 'utf8').digest('hex');
32
38
  }
39
+ /**
40
+ * Both ids in a handoff path are caller-supplied path segments, so both are
41
+ * checked at the join. Added 2026-09-14 (repair R1, security audit F2 of
42
+ * `2026-09-14-cli-id-escape-instrumentation`).
43
+ *
44
+ * This function was introduced by `0536d5bd` — the commit that instrumented
45
+ * this defect class — with neither id checked, and it sat outside rule D's
46
+ * scanned layer, so the instrument could not see its own new member.
47
+ * Measured on the pre-fix tree (`prd handoff init --apply`, temp project,
48
+ * `ok: true` both times):
49
+ *
50
+ * --rid '../../../../../../README' replaced the project-root README.md
51
+ * --sid '../../../../SIDOUT' wrote 4 levels above the project root
52
+ *
53
+ * The two axes need two different controls, for a recorded reason: the rid has
54
+ * a pinned format (`REQUEST_ID_PATTERN`, no separator, no dot-dot, no drive)
55
+ * and the sid has none, so it gets the segment check. `isUnsafePathInput`
56
+ * alone is NOT enough for the rid — it admits `a/b` (two non-empty segments),
57
+ * which `request-artifact-service.ts` would reject.
58
+ *
59
+ * Guarding HERE rather than at the three `prd`/`env` flags means every producer
60
+ * that writes through this constructor — `initHandoff`'s default,
61
+ * `handoff-auto-regen.ts`, `evidence-generator.ts` — is covered by the join
62
+ * itself, not by each caller re-deciding.
63
+ */
64
+ function assertSafeHandoffIds(sessionId, requestId) {
65
+ if (!REQUEST_ID_PATTERN.test(requestId)) {
66
+ throw new Error(`Invalid request id: ${requestId} (expected letters, digits, dots, underscores, or dashes)`);
67
+ }
68
+ if (isUnsafePathInput(sessionId)) {
69
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
70
+ }
71
+ }
72
+ /**
73
+ * The canonical capsule path for ONE SLICE, relative to the project root.
74
+ *
75
+ * Slice `2026-09-14-prd-capsule-rid-scoping`: the capsule used to be one slot
76
+ * per SESSION (`prd/handoff.md`), so the second slice's handoff silently
77
+ * overwrote the first slice's — and `AUDIT_REQUIRES_HANDOFF` stayed green
78
+ * because it never checked WHOSE rid the file named. Measured on
79
+ * `2026-09-13-session-21878f`: a four-slice job passed that prerequisite on a
80
+ * capsule left by a different line of work.
81
+ *
82
+ * This is the WRITE target, so it always carries the rid — a consumer-side
83
+ * fallback here would leave a rid-scoped requirement with a bare-name writer,
84
+ * which is the defect shape this slice exists to remove. Readers that must
85
+ * tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
86
+ */
87
+ export function handoffRelativePath(sessionId, requestId) {
88
+ assertSafeHandoffIds(sessionId, requestId);
89
+ return join('.peaks', '_runtime', sessionId, 'prd', `handoff-${requestId}.md`);
90
+ }
91
+ /**
92
+ * The capsule a CONSUMER should read for (session, requestId): the rid-scoped
93
+ * path when it is on disk, else the pre-rid-scoping bare name. Returns null
94
+ * when neither exists.
95
+ *
96
+ * Three sessions on disk still hold only the bare file
97
+ * (`2026-09-06-session-a87ca4`, `2026-09-12-session-e37ef0`,
98
+ * `2026-09-13-session-21878f`), so the legacy tier has to keep resolving for
99
+ * the gate and for every reader below.
100
+ *
101
+ * `requestId` is optional because the detect-only audit surface reaches its
102
+ * service without one. Such a caller can name only the bare path: a session
103
+ * holding nothing but rid-scoped capsules reports missing rather than picking
104
+ * among its siblings' capsules, which would re-open the cross-slice mix-up
105
+ * this scoping exists to close (fail closed, not "some capsule is there").
106
+ */
107
+ export function resolveHandoffPath(opts) {
108
+ // The legacy bare-name candidate below is a second join of the same sid, in a
109
+ // second function, so it needs the sid guarded in its own right — the
110
+ // optional-requestId branch reaches `handoffRelativePath` (guarded there), but
111
+ // the branch that is taken when a caller has NO rid reaches this join only.
112
+ if (isUnsafePathInput(opts.sessionId)) {
113
+ throw new Error(`Invalid session id: ${opts.sessionId} (must be a single path segment)`);
114
+ }
115
+ const candidates = [
116
+ ...(opts.requestId !== undefined ? [handoffRelativePath(opts.sessionId, opts.requestId)] : []),
117
+ join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md')
118
+ ];
119
+ for (const relative of candidates) {
120
+ const absolute = join(opts.projectRoot, relative);
121
+ if (existsSync(absolute))
122
+ return absolute;
123
+ }
124
+ return null;
125
+ }
33
126
  /** Pure: produce a Handoff with the frontmatter populated. Hash is
34
127
  * computed here; callers MUST NOT pre-populate `handoffHash`. */
35
128
  export function initHandoff(opts) {
36
129
  const handoffPath = opts.handoffPath ??
37
- join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md');
130
+ handoffRelativePath(opts.sessionId, opts.requestId);
38
131
  const handoffHash = sha256OfBody(opts.body);
39
132
  const frontmatter = {
40
133
  requestId: opts.requestId,
@@ -146,8 +239,11 @@ function parseHandoffContent(content) {
146
239
  };
147
240
  }
148
241
  function serializeHandoff(handoff) {
149
- const yamlStr = stringifyYaml(handoff.frontmatter).trimEnd();
150
- return `---\n${yamlStr}\n---\n${handoff.body}`;
242
+ // The one canonical frontmatter rendering, shared with
243
+ // `handoff-auto-regen.ts`. `yaml.stringify` used to render this block and
244
+ // emitted `schemaVersion: "2"` + a bare `handoffHash:`, which the
245
+ // `AUDIT_REQUIRES_HANDOFF` gate and both audit loaders all reject.
246
+ return `${serializeHandoffFrontmatter(handoff.frontmatter)}${handoff.body}`;
151
247
  }
152
248
  /**
153
249
  * N1: accept BOTH shapes of `schemaVersion` — the string `'2'` and the bare
@@ -161,13 +257,33 @@ function serializeHandoff(handoff) {
161
257
  * readable handoff was satisfied by a handoff the parser would not read, and
162
258
  * `peaks prd handoff verify` exited 1 on a healthy file.
163
259
  *
164
- * Quoting the writer instead is NOT a fix: it would delete the very substring
165
- * the prereq pins, turning a broken read into a broken gate. The tolerant read
166
- * is the only change that satisfies both consumers.
260
+ * Slice `2026-09-14-handoff-writer-gate-divergence` then fixed the other half:
261
+ * the writer no longer emits the quoted form at all (see
262
+ * `handoff-frontmatter.ts`). This tolerance stays because handoffs already on
263
+ * disk were written by the old writer and by hand; the writer fix must not
264
+ * retroactively make them unreadable.
167
265
  */
168
266
  function isSchemaVersion2(value) {
169
267
  return value === HANDOFF_SCHEMA_VERSION || value === 2;
170
268
  }
269
+ /**
270
+ * The READER's shape check — deliberately looser than `verifyHandoff`:
271
+ * `handoffHash` must be a string, and nothing about its VALUE is validated here.
272
+ *
273
+ * The accepted-but-unverifiable shape, stated rather than left to be
274
+ * discovered: a capsule carrying `handoffHash: sha256:<hex>` — the form the
275
+ * pre-fix writer and this session's hand-corrected capsules use — is READ by
276
+ * `readHandoff` and can NEVER pass `verifyHandoff`. The verifier compares the
277
+ * value to `sha256OfBody(body)` byte for byte (`:127-135`) and no prefix
278
+ * normalization exists anywhere in this module, so the leading `sha256:`
279
+ * guarantees `hash-mismatch`. ONLY the bare-hex form verifies, which is what
280
+ * every writer now emits (`handoff-frontmatter.ts`).
281
+ *
282
+ * So `readHandoff` succeeding is NOT evidence that a capsule is verifiable —
283
+ * the tolerance above exists so old capsules stay READABLE, not so they become
284
+ * acceptable. Request §三 bullet 3 asked for exactly this confirmation
285
+ * (rid `2026-09-14-handoff-writer-gate-divergence`).
286
+ */
171
287
  function isHandoffFrontmatter(value) {
172
288
  if (!value || typeof value !== 'object')
173
289
  return false;
@@ -14,8 +14,9 @@
14
14
  * (D1 in `v2-11-rm-rd-techdoc-immutable-handoff`).
15
15
  *
16
16
  * Path convention: the file lands at
17
- * `.peaks/_runtime/<sessionId>/prd/handoff.md` (gitignored session
18
- * artifact; the binding to `<sessionId>` lives in
17
+ * `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
18
+ * `.peaks/_runtime/<sessionId>/prd/handoff.md` is the pre-rid-scoping tier and
19
+ * stays readable). Gitignored session artifact; the binding to `<sessionId>` lives in
19
20
  * `.peaks/_runtime/current-change`). NEVER write under
20
21
  * `.peaks/_runtime/<change-id>/...` directly (slice 2.8.3 hard ban).
21
22
  */
@@ -14,8 +14,9 @@
14
14
  * (D1 in `v2-11-rm-rd-techdoc-immutable-handoff`).
15
15
  *
16
16
  * Path convention: the file lands at
17
- * `.peaks/_runtime/<sessionId>/prd/handoff.md` (gitignored session
18
- * artifact; the binding to `<sessionId>` lives in
17
+ * `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
18
+ * `.peaks/_runtime/<sessionId>/prd/handoff.md` is the pre-rid-scoping tier and
19
+ * stays readable). Gitignored session artifact; the binding to `<sessionId>` lives in
19
20
  * `.peaks/_runtime/current-change`). NEVER write under
20
21
  * `.peaks/_runtime/<change-id>/...` directly (slice 2.8.3 hard ban).
21
22
  */
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
20
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
19
21
  /** 6-item business checklist (12 Gaps QA perspective). */
20
22
  export const QA_BUSINESS_ITEMS = [
21
23
  { id: 'business-flow', question: '这个功能"用起来"对吗?(业务流程顺不顺,操作路径是否反人类,跟现有系统交互有没有断层)' },
@@ -36,9 +38,30 @@ export function buildEmptyQaReview(requestId, sessionId, now = new Date()) {
36
38
  };
37
39
  }
38
40
  export function getQaReviewDir(projectRoot, sessionId) {
41
+ // Sid axis ONLY. The guard here covers the session segment of every
42
+ // `peaks qa-business-review|score|accept|reject` subcommand — measured:
43
+ // `--session-id ../../../../…/PWNED` wrote `qa-business-reviews/<rid>.json`
44
+ // outside every project root under an `ok: true` envelope (RD sweep case A17).
45
+ //
46
+ // It does NOT cover the rid axis: the request id is joined one function later,
47
+ // in `getQaReviewPath` below. Corrected 2026-09-14 (repair R1) — this comment
48
+ // previously said "one guard here covers the whole family", and the security
49
+ // audit of `2026-09-14-cli-id-escape-instrumentation` (F1b) measured that claim
50
+ // false: `qa-business-review '../../../../…/EVILQA3'` wrote a `.json` file
51
+ // outside the project root under `ok: true`.
52
+ if (isUnsafePathInput(sessionId)) {
53
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
54
+ }
39
55
  return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'qa-business-reviews');
40
56
  }
41
57
  export function getQaReviewPath(projectRoot, sessionId, requestId) {
58
+ // Rid axis. The requestId is the CLI positional (`qa-business-review
59
+ // <request-id>`), so it is caller-supplied and it becomes a filename segment
60
+ // here. `isUnsafePathInput` alone would admit `a/b`; the rid has a pinned
61
+ // format, so the format check is the control.
62
+ if (!REQUEST_ID_PATTERN.test(requestId)) {
63
+ throw new Error(`Invalid request id: ${requestId} (expected letters, digits, dots, underscores, or dashes)`);
64
+ }
42
65
  return join(getQaReviewDir(projectRoot, sessionId), `${requestId}.json`);
43
66
  }
44
67
  export function readQaReview(projectRoot, sessionId, requestId) {
@@ -51,6 +51,14 @@ export type CommitBoundary = {
51
51
  syncState: 'synced' | 'pending' | 'failed';
52
52
  rollbackPoint: string | null;
53
53
  };
54
+ /**
55
+ * The repo's slice-id control — no separator, no drive, no `..`, non-empty, and
56
+ * not a bare `.`/`..`. Exported 2026-09-14 (repair R1) so the slice-id axis has
57
+ * ONE control: `slice-review-state.getReviewPath` joins a caller-supplied slice
58
+ * id into a filename and previously had none, and a second copy of this regex
59
+ * there would be the same axis decided twice.
60
+ */
61
+ export declare const SLICE_ID_PATTERN: RegExp;
54
62
  /**
55
63
  * Resolution sources for `resolveArtifactSession`, in priority order.
56
64
  * - `active-skill`: the canonical sid-scoped lease projection
@@ -44,7 +44,14 @@ const MODERN_RETENTION_REQUIREMENTS = [
44
44
  'qa/test-reports/{sliceId}.md',
45
45
  'txt/handoff.md'
46
46
  ];
47
- const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
47
+ /**
48
+ * The repo's slice-id control — no separator, no drive, no `..`, non-empty, and
49
+ * not a bare `.`/`..`. Exported 2026-09-14 (repair R1) so the slice-id axis has
50
+ * ONE control: `slice-review-state.getReviewPath` joins a caller-supplied slice
51
+ * id into a filename and previously had none, and a second copy of this regex
52
+ * there would be the same axis decided twice.
53
+ */
54
+ export const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
48
55
  function getPeaksPath(workspaceRoot) {
49
56
  return resolve(workspaceRoot, '.peaks');
50
57
  }
@@ -68,7 +68,7 @@ export async function scanKarpathy(options) {
68
68
  warnings: [
69
69
  `Karpathy review file missing: ${reviewRel}`,
70
70
  'Per karpathy §1 Think Before Coding: state your assumptions. Without a review file, no 5-way fanout evidence is available.',
71
- 'Per karpathy §3 Surgical Changes: touch only what the request requires. Create a minimal rd/karpathy-review.md stub before requesting qa-handoff.'
71
+ 'Per karpathy §3 Surgical Changes: touch only what the request requires. The rd:qa-handoff gate is satisfied by rd/karpathy-review-<rid>.md; this scanner has no rid and reads only the back-compat name rd/karpathy-review.md, so a rid-scoped-only slice still reads as missing here. `peaks request transition --state qa-handoff` is the authoritative gate.'
72
72
  ]
73
73
  };
74
74
  }
@@ -223,6 +223,6 @@ export function formatKarpathyMarkdown(report, opts = {}) {
223
223
  }
224
224
  lines.push('### Karpathy-Gate');
225
225
  lines.push('');
226
- lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review.md` to be present with all 4 guideline sections before `peaks request transition --state qa-handoff`.');
226
+ lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review-<rid>.md` to be present with all 4 guideline sections before `peaks request transition --state qa-handoff`. (`rd/karpathy-review.md` is the accepted back-compat tier, and the only name this scanner probes.)');
227
227
  return lines.join('\n');
228
228
  }