peaks-loop 4.0.48 → 4.0.50

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 (156) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/compact-command.js +1 -3
  7. package/dist/cli/commands/core/skill-command.js +53 -4
  8. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  9. package/dist/cli/commands/core/standards-command.js +74 -0
  10. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  11. package/dist/cli/commands/feedback-commands.js +49 -17
  12. package/dist/cli/commands/final-review-commands.js +12 -0
  13. package/dist/cli/commands/hooks-commands.js +55 -38
  14. package/dist/cli/commands/loop-eval-commands.js +22 -6
  15. package/dist/cli/commands/share-commands.js +37 -11
  16. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  17. package/dist/cli/commands/web-commands.js +8 -1
  18. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  19. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  20. package/dist/services/adapter/adapter.d.ts +30 -0
  21. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  22. package/dist/services/adapter/claude-adapter.js +12 -0
  23. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  24. package/dist/services/adapter/codex-adapter.js +12 -0
  25. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  26. package/dist/services/adapter/copilot-adapter.js +12 -0
  27. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  28. package/dist/services/artifacts/request-artifact-service.js +59 -38
  29. package/dist/services/audit/backing-detector.d.ts +25 -7
  30. package/dist/services/audit/backing-detector.js +33 -17
  31. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  32. package/dist/services/audit/enforcer-liveness.js +100 -0
  33. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  34. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  35. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  36. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  37. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  38. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  39. package/dist/services/audit/enforcers/lint-style.js +38 -2
  40. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  41. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  42. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  43. package/dist/services/audit/red-lines-service.js +51 -7
  44. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  45. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  46. package/dist/services/capability-audit-service/index.d.ts +3 -1
  47. package/dist/services/capability-audit-service/index.js +1 -0
  48. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  49. package/dist/services/capability-audit-service/runner.js +76 -15
  50. package/dist/services/capability-audit-service/types.d.ts +48 -0
  51. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  52. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  53. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  54. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  55. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  56. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  57. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  58. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  59. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  60. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  61. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  62. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  63. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  64. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  65. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  66. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  67. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  68. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  69. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  70. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  71. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  72. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  73. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  74. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  75. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  76. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  77. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  78. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  79. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  80. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  81. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  82. package/dist/services/capability-guard-runner/registry.js +140 -0
  83. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  84. package/dist/services/capability-guard-runner/runner.js +63 -6
  85. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  86. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  87. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  88. package/dist/services/code/auto-compact-modes.js +20 -4
  89. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  90. package/dist/services/code/compact-event-settle.d.ts +20 -8
  91. package/dist/services/code/compact-event-settle.js +21 -0
  92. package/dist/services/code/post-compact-detector.js +20 -11
  93. package/dist/services/code/step-08-gate.js +21 -6
  94. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  95. package/dist/services/config/config-safety.js +11 -9
  96. package/dist/services/context/auto-compact-types.d.ts +20 -2
  97. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  98. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  99. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  100. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  101. package/dist/services/final-review/pre-post-diff.js +10 -2
  102. package/dist/services/job/job-progress-store.js +18 -3
  103. package/dist/services/observability/jsonl-store.d.ts +19 -0
  104. package/dist/services/observability/jsonl-store.js +27 -2
  105. package/dist/services/observability/observability-service.d.ts +11 -4
  106. package/dist/services/observability/observability-service.js +16 -3
  107. package/dist/services/prd/handoff-service.js +43 -0
  108. package/dist/services/qa/qa-business-review-state.js +19 -5
  109. package/dist/services/sc/sc-service.d.ts +8 -0
  110. package/dist/services/sc/sc-service.js +8 -1
  111. package/dist/services/scan/api-diff-types.js +20 -2
  112. package/dist/services/security/safe-settings-path.js +19 -1
  113. package/dist/services/session/getSessionDir.d.ts +33 -0
  114. package/dist/services/session/getSessionDir.js +60 -0
  115. package/dist/services/skill/skill-search-service.d.ts +3 -3
  116. package/dist/services/slice/slice-review-state.js +19 -4
  117. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  118. package/dist/services/standards/loop-engineering-lint.js +6 -0
  119. package/dist/services/web/daemon-registry.js +27 -2
  120. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  121. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  122. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  123. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  124. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  125. package/dist/services/workspace/claude-settings-template.js +105 -83
  126. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  127. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  128. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  129. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  130. package/dist/services/workspace/workspace-service.js +11 -1
  131. package/dist/shared/fs-utils.d.ts +26 -0
  132. package/dist/shared/fs-utils.js +35 -0
  133. package/dist/shared/runtime-root.d.ts +73 -0
  134. package/dist/shared/runtime-root.js +77 -0
  135. package/package.json +9 -7
  136. package/scripts/copy-templates.mjs +0 -12
  137. package/scripts/install-skills.mjs +154 -53
  138. package/skills/bee/peaks-qa/SKILL.md +0 -1
  139. package/skills/bee/peaks-rd/SKILL.md +0 -1
  140. package/skills/peaks-code/SKILL.md +12 -10
  141. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  142. package/skills/peaks-code/references/runbook.md +3 -0
  143. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  144. package/skills/peaks-code/references/startup-sequence.md +2 -2
  145. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  146. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  147. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  148. package/dist/cli/commands/context-builder-commands.js +0 -85
  149. package/dist/services/hooks/write-gate.js +0 -111
  150. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  151. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  152. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  153. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  154. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  155. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  156. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -27,6 +27,8 @@ import { existsSync } from 'node:fs';
27
27
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
28
28
  import { dirname, join } from 'node:path';
29
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';
30
32
  import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
31
33
  /** Required schema version for new handoffs. */
32
34
  const HANDOFF_SCHEMA_VERSION = '2';
@@ -34,6 +36,39 @@ const HANDOFF_SCHEMA_VERSION = '2';
34
36
  export function sha256OfBody(body) {
35
37
  return createHash('sha256').update(body, 'utf8').digest('hex');
36
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
+ }
37
72
  /**
38
73
  * The canonical capsule path for ONE SLICE, relative to the project root.
39
74
  *
@@ -50,6 +85,7 @@ export function sha256OfBody(body) {
50
85
  * tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
51
86
  */
52
87
  export function handoffRelativePath(sessionId, requestId) {
88
+ assertSafeHandoffIds(sessionId, requestId);
53
89
  return join('.peaks', '_runtime', sessionId, 'prd', `handoff-${requestId}.md`);
54
90
  }
55
91
  /**
@@ -69,6 +105,13 @@ export function handoffRelativePath(sessionId, requestId) {
69
105
  * this scoping exists to close (fail closed, not "some capsule is there").
70
106
  */
71
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
+ }
72
115
  const candidates = [
73
116
  ...(opts.requestId !== undefined ? [handoffRelativePath(opts.sessionId, opts.requestId)] : []),
74
117
  join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md')
@@ -17,6 +17,7 @@
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { isUnsafePathInput } from '../../shared/path-safety.js';
20
+ import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
20
21
  /** 6-item business checklist (12 Gaps QA perspective). */
21
22
  export const QA_BUSINESS_ITEMS = [
22
23
  { id: 'business-flow', question: '这个功能"用起来"对吗?(业务流程顺不顺,操作路径是否反人类,跟现有系统交互有没有断层)' },
@@ -37,17 +38,30 @@ export function buildEmptyQaReview(requestId, sessionId, now = new Date()) {
37
38
  };
38
39
  }
39
40
  export function getQaReviewDir(projectRoot, sessionId) {
40
- // Sid axis. Every `peaks qa-business-review|score|accept|reject` subcommand
41
- // reaches the runtime tree through this one constructor, so one guard here
42
- // covers the whole family — measured: `--session-id ../../../../…/PWNED`
43
- // wrote `qa-business-reviews/<rid>.json` outside every project root under an
44
- // `ok: true` envelope (RD sweep case A17).
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`.
45
52
  if (isUnsafePathInput(sessionId)) {
46
53
  throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
47
54
  }
48
55
  return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'qa-business-reviews');
49
56
  }
50
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
+ }
51
65
  return join(getQaReviewDir(projectRoot, sessionId), `${requestId}.json`);
52
66
  }
53
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
  }
@@ -8,7 +8,25 @@
8
8
  * is not merely annotated. `confidence: 'exact'` is reserved for lines where
9
9
  * both sides were parsed AND the recorded interface was fully readable.
10
10
  */
11
+ import { realpathSync } from 'node:fs';
11
12
  import { resolve, sep } from 'node:path';
13
+ /**
14
+ * Canonicalize through symlinks so two paths that name the same directory through
15
+ * different prefixes (the macOS `/var` <-> `/private/var` quirk the brief calls
16
+ * out) line up as the same literal on both sides of the prefix test below.
17
+ * Falls back to `resolve()` when the path does not exist yet — `realpathSync`
18
+ * throws on missing paths, and several callers in the suite inspect not-yet-
19
+ * created paths (e.g. the "string prefix of a sibling" guard at
20
+ * api-diff-service.test.ts:462).
21
+ */
22
+ function safeRealpath(p) {
23
+ try {
24
+ return realpathSync(p);
25
+ }
26
+ catch {
27
+ return resolve(p);
28
+ }
29
+ }
12
30
  /** The honest boundary of the feature. Printed in the output, not just documented. */
13
31
  export const NOT_DETECTABLE = [
14
32
  'a field whose type is unchanged but whose meaning changed',
@@ -38,8 +56,8 @@ export function isRecord(value) {
38
56
  * the misleading `archive/docs/api.json`.
39
57
  */
40
58
  export function toDisplayPath(projectRoot, file) {
41
- const rootParts = resolve(projectRoot).split(sep);
42
- const fileParts = resolve(file).split(sep);
59
+ const rootParts = safeRealpath(projectRoot).split(sep);
60
+ const fileParts = safeRealpath(file).split(sep);
43
61
  const inside = fileParts.length > rootParts.length
44
62
  && rootParts.every((part, index) => part === fileParts[index]);
45
63
  return (inside ? fileParts.slice(rootParts.length) : fileParts).join('/');
@@ -71,12 +71,30 @@ export function assertSafeDispatchRecordPath(recordPath, projectRoot) {
71
71
  // to create it). Fall back to lexical comparison against the
72
72
  // canonical projectRoot — the write will then create the file,
73
73
  // and any symlink in the parent will be caught on the next read.
74
+ //
75
+ // macOS note: the project's own realpath may still resolve the
76
+ // `/var -> /private/var` symlink even when the sub-agent dir does
77
+ // not exist yet, so the caller can compare on a stable prefix
78
+ // instead of having one side of the comparison on `/var/...` and
79
+ // the other on `/private/var/...`. We still validate that the
80
+ // canonical record path lives under the canonical projectRoot.
74
81
  const fallback = resolve(projectRoot, '.peaks', SUB_AGENTS_DIR);
75
82
  const rel2 = relative(fallback, recordPath);
76
83
  if (rel2.startsWith('..') || isAbsolute(rel2)) {
77
84
  throw invalidPathError(recordPath, 'must be under .peaks/_sub_agents/');
78
85
  }
79
- return recordPath;
86
+ try {
87
+ realRoot = realpathSync(projectRoot);
88
+ const canonicalRecord = resolve(realRoot, '.peaks', SUB_AGENTS_DIR, recordPath.slice(fallback.length + 1));
89
+ const realRel = relative(realRoot, canonicalRecord);
90
+ if (realRel.startsWith('..' + sep) || realRel === '..' || isAbsolute(realRel)) {
91
+ throw invalidPathError(recordPath, 'escapes project root via symlink');
92
+ }
93
+ return canonicalRecord;
94
+ }
95
+ catch {
96
+ return recordPath;
97
+ }
80
98
  }
81
99
  const realRel = relative(realRoot, realRecord);
82
100
  if (realRel.startsWith('..' + sep) || realRel === '..' || isAbsolute(realRel)) {
@@ -1 +1,34 @@
1
1
  export declare function getSessionDir(projectRoot: string, sessionId: string): string;
2
+ /**
3
+ * The TOTAL entry to the same axis. Same predicate, same path; the only
4
+ * difference is that this one is total — it never throws.
5
+ *
6
+ * WHY TWO ENTRIES RATHER THAN ONE. The partial entry above is correct
7
+ * for a caller that cannot proceed without a session dir: a throw is
8
+ * the one failure a caller cannot forget to handle. It is the WRONG
9
+ * shape for a frame whose own doc promises never to throw — the
10
+ * statusline, the fire-and-forget telemetry writer, the best-effort
11
+ * probe. Each of those frames had to wrap the partial entry in a
12
+ * `try { } catch { return null }`, and that swallow cannot be told
13
+ * apart from a real "there is nothing here" answer. Measured on the
14
+ * compact backoff (`auto-compact-lifecycle.ts`), the conflation turned
15
+ * an unresolvable id into the ADMIT branch and re-opened a dispatch
16
+ * that the open run should have suppressed.
17
+ *
18
+ * The split does NOT make a swallow unwriteable — TypeScript has no
19
+ * checked exceptions, so nothing here can. It makes the swallow
20
+ * UNNECESSARY at a named place, and it gives every checker a stable
21
+ * name to key on: a frame that degrades must say so by calling this
22
+ * function, and the degrading branch (`ok: false`) is then a value the
23
+ * caller has to handle rather than a `catch` nobody reads.
24
+ *
25
+ * `reason` is a single-line English sentence fit for an envelope — no
26
+ * stack traces, no CLI verbs (see `human-nl-choice-only-tenet`).
27
+ */
28
+ export declare function tryGetSessionDir(projectRoot: string, sessionId: string): {
29
+ readonly ok: true;
30
+ readonly dir: string;
31
+ } | {
32
+ readonly ok: false;
33
+ readonly reason: string;
34
+ };
@@ -22,11 +22,71 @@
22
22
  * legacy `.peaks/<sid>/...` artifact path that a sub-agent would
23
23
  * follow verbatim.
24
24
  *
25
+ * `sessionId` is the last caller-supplied segment of every session-scoped
26
+ * path here, so its segment check lives at this join rather than being
27
+ * re-derived at each call site: `../../x` used to be joined verbatim, and
28
+ * the CLI wrote outside the project root while still returning `ok: true`.
29
+ *
30
+ * The predicate is `isUnsafePathInput`, NOT `SESSION_ID_PATTERN` /
31
+ * `validateSessionId`. The latter are stricter than the segment axis and
32
+ * reject ids that are legal today (`sid-1`, a request id reused as the
33
+ * session-dir name), so adopting them would change results for
34
+ * well-formed callers.
35
+ *
36
+ * Not covered, recorded rather than implied: the `\0` axis
37
+ * (`isUnsafePathInput` admits a NUL, and `execFileSync` raises EINVAL
38
+ * before it can be exercised), and callers that hand-roll
39
+ * `join(root, '.peaks', '_runtime', sid, ...)` instead of calling this.
40
+ *
25
41
  * @param projectRoot - Absolute path to the project root.
26
42
  * @param sessionId - The session identifier (e.g. `2026-06-06-session-5b1095`).
27
43
  * @returns Absolute path to the canonical session directory.
44
+ * @throws Error when `sessionId` is not a single path segment.
28
45
  */
29
46
  import { join } from 'node:path';
47
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
30
48
  export function getSessionDir(projectRoot, sessionId) {
49
+ // Throwing, not `null` / a tagged result: those widen the return type
50
+ // to `string | null` and make every call site handle a bad id — the
51
+ // per-site slice this guard replaces. This is also the shape the repo
52
+ // already refuses with, and a caller cannot forget to handle a throw.
53
+ if (isUnsafePathInput(sessionId)) {
54
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
55
+ }
31
56
  return join(projectRoot, '.peaks', '_runtime', sessionId);
32
57
  }
58
+ /**
59
+ * The TOTAL entry to the same axis. Same predicate, same path; the only
60
+ * difference is that this one is total — it never throws.
61
+ *
62
+ * WHY TWO ENTRIES RATHER THAN ONE. The partial entry above is correct
63
+ * for a caller that cannot proceed without a session dir: a throw is
64
+ * the one failure a caller cannot forget to handle. It is the WRONG
65
+ * shape for a frame whose own doc promises never to throw — the
66
+ * statusline, the fire-and-forget telemetry writer, the best-effort
67
+ * probe. Each of those frames had to wrap the partial entry in a
68
+ * `try { } catch { return null }`, and that swallow cannot be told
69
+ * apart from a real "there is nothing here" answer. Measured on the
70
+ * compact backoff (`auto-compact-lifecycle.ts`), the conflation turned
71
+ * an unresolvable id into the ADMIT branch and re-opened a dispatch
72
+ * that the open run should have suppressed.
73
+ *
74
+ * The split does NOT make a swallow unwriteable — TypeScript has no
75
+ * checked exceptions, so nothing here can. It makes the swallow
76
+ * UNNECESSARY at a named place, and it gives every checker a stable
77
+ * name to key on: a frame that degrades must say so by calling this
78
+ * function, and the degrading branch (`ok: false`) is then a value the
79
+ * caller has to handle rather than a `catch` nobody reads.
80
+ *
81
+ * `reason` is a single-line English sentence fit for an envelope — no
82
+ * stack traces, no CLI verbs (see `human-nl-choice-only-tenet`).
83
+ */
84
+ export function tryGetSessionDir(projectRoot, sessionId) {
85
+ // Deliberately NOT `try { return {ok:true, dir: getSessionDir(...)} } catch`.
86
+ // That would re-introduce the swallow this function exists to remove, and it
87
+ // would also catch a throw from `join` for reasons that are not a bad id.
88
+ if (isUnsafePathInput(sessionId)) {
89
+ return { ok: false, reason: `Invalid session id: ${sessionId} (must be a single path segment)` };
90
+ }
91
+ return { ok: true, dir: join(projectRoot, '.peaks', '_runtime', sessionId) };
92
+ }
@@ -32,14 +32,14 @@ export declare const SkillSearchInputSchema: z.ZodObject<{
32
32
  status: "status";
33
33
  resume: "resume";
34
34
  audit: "audit";
35
- ide: "ide";
35
+ "final-review": "final-review";
36
36
  content: "content";
37
+ ide: "ide";
37
38
  test: "test";
38
- research: "research";
39
39
  doctor: "doctor";
40
+ research: "research";
40
41
  triage: "triage";
41
42
  sop: "sop";
42
- "final-review": "final-review";
43
43
  "slice-decompose": "slice-decompose";
44
44
  "issue-fix-orchestrator": "issue-fix-orchestrator";
45
45
  "perf-audit": "perf-audit";
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { isUnsafePathInput } from '../../shared/path-safety.js';
18
+ import { SLICE_ID_PATTERN } from '../sc/sc-service.js';
18
19
  /** The 5 default review items per slice (the 12 Gaps memory checklist). */
19
20
  export const DEFAULT_REVIEW_ITEMS = [
20
21
  { id: 'business-match', question: '这个 slice 做完,业务流程对吗?(跟产品最初给的需求匹配)' },
@@ -33,16 +34,30 @@ export function buildEmptySliceReview(sliceId, sessionId, now = new Date()) {
33
34
  };
34
35
  }
35
36
  export function getReviewDir(projectRoot, sessionId) {
36
- // Sid axis. Every `peaks slice-review|score|accept|reject` subcommand reaches
37
- // the runtime tree through this one constructor. Measured: `--session-id
38
- // ../../../../…/PWNED` wrote `slice-reviews/<slice-id>.json` outside every
39
- // project root under an `ok: true` envelope (RD sweep case A26).
37
+ // Sid axis ONLY. Every `peaks slice-review|score|accept|reject` subcommand
38
+ // reaches the runtime tree through this one constructor. Measured:
39
+ // `--session-id ../../../../…/PWNED` wrote `slice-reviews/<slice-id>.json`
40
+ // outside every project root under an `ok: true` envelope (RD sweep case A26).
41
+ //
42
+ // It does NOT cover the slice-id axis: the slice id is joined one function
43
+ // later, in `getReviewPath` below. Corrected 2026-09-14 (repair R1) — this
44
+ // comment previously said one guard covered the whole family; the security
45
+ // audit of `2026-09-14-cli-id-escape-instrumentation` (F1b) measured that
46
+ // false: `slice-review '../../../../…/EVILSL'` wrote a `.json` file outside
47
+ // the project root under `ok: true`.
40
48
  if (isUnsafePathInput(sessionId)) {
41
49
  throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
42
50
  }
43
51
  return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'slice-reviews');
44
52
  }
45
53
  export function getReviewPath(projectRoot, sessionId, sliceId) {
54
+ // Slice-id axis. The slice id is the CLI positional (`slice-review
55
+ // <slice-id>`), so it is caller-supplied and it becomes a filename segment
56
+ // here. Same control as the rid axis: a pinned format beats the segment check,
57
+ // which admits `a/b`.
58
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
59
+ throw new Error(`Invalid slice id: ${sliceId} (expected letters, digits, dots, underscores, or dashes)`);
60
+ }
46
61
  return join(getReviewDir(projectRoot, sessionId), `${sliceId}.json`);
47
62
  }
48
63
  export function readSliceReview(projectRoot, sessionId, sliceId) {
@@ -18,7 +18,7 @@
18
18
  * `peaks standards lint --category loop-engineering` (registered in M0's
19
19
  * plan) will read this file from disk and call this function.
20
20
  */
21
- export declare const EXPECTED_RED_LINE_IDS: readonly ["RL-0", "RL-1", "RL-2", "RL-3", "RL-4", "RL-5", "RL-6", "RL-7", "RL-8", "RL-9"];
21
+ export declare const EXPECTED_RED_LINE_IDS: readonly ["RL-0", "RL-1", "RL-2", "RL-3", "RL-4", "RL-5", "RL-6", "RL-7", "RL-8", "RL-9", "RL-10"];
22
22
  export type RedLineId = (typeof EXPECTED_RED_LINE_IDS)[number];
23
23
  export declare const REQUIRED_SECTIONS: readonly ["Failure modes", "Rewrite", "Self-check", "Out-of-scope"];
24
24
  export type RedLineSection = (typeof REQUIRED_SECTIONS)[number];
@@ -29,6 +29,12 @@ export const EXPECTED_RED_LINE_IDS = [
29
29
  'RL-7',
30
30
  'RL-8',
31
31
  'RL-9',
32
+ // RL-10 was written into the guideline file (its 4 sections included) but
33
+ // never added here, so the lint could not see it: the closed set stopped one
34
+ // short of the file it was supposed to police. The file's own footer said
35
+ // "Total red lines: 9 (RL-0..RL-9)" while RL-10 sat below it — the two
36
+ // statements contradicted each other and only the file was ever read.
37
+ 'RL-10',
32
38
  ];
33
39
  export const REQUIRED_SECTIONS = [
34
40
  'Failure modes',
@@ -5,10 +5,29 @@
5
5
  * WRITE goes through `assertUnder` first — the slice-wide guard against an
6
6
  * artifact escaping `<root>/.peaks/_runtime/<sid>/web/` (AC1).
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { dirname } from 'node:path';
10
10
  import { assertUnder, webDaemonDir, webDaemonInfoPath, webSpawnLockPath } from './web-artifact-paths.js';
11
11
  import { parseDaemonInfo } from './web-protocol.js';
12
+ /**
13
+ * Canonicalize through symlinks so two paths that name the same directory through
14
+ * different prefixes compare equal — the macOS `/var` <-> `/private/var` quirk
15
+ * makes `mkdtempSync(...)` return `/var/folders/...` while `process.cwd()` after
16
+ * `chdir` returns `/private/var/folders/...`. They are the same directory on
17
+ * disk; without `realpathSync` the ownership check below silently rejects a
18
+ * legitimate record and the CLI cold-starts a real daemon instead of reusing
19
+ * the stub. Falls back to `resolve()` when the path is absent: a planted
20
+ * record naming a directory that does not exist still has to fail the
21
+ * containment test, and a `realpathSync` on it would throw.
22
+ */
23
+ function safeRealpath(p) {
24
+ try {
25
+ return realpathSync(p);
26
+ }
27
+ catch {
28
+ return p;
29
+ }
30
+ }
12
31
  /** A spawn lock older than this is reclaimed even if its owner pid is alive. */
13
32
  const SPAWN_LOCK_STALE_MS = 120_000;
14
33
  /**
@@ -46,7 +65,13 @@ export function readDaemonInfo(projectRoot, sessionId) {
46
65
  }
47
66
  try {
48
67
  const info = parseDaemonInfo(readFileSync(target, 'utf8'));
49
- if (info === null || info.projectRoot !== projectRoot || info.sessionId !== sessionId) {
68
+ if (info === null || info.sessionId !== sessionId) {
69
+ return null;
70
+ }
71
+ // Canonicalize both sides — a record written from one prefix (say
72
+ // `/var/folders/...`) and read from the canonical one (`/private/var/...`)
73
+ // describes the same project on macOS.
74
+ if (safeRealpath(info.projectRoot) !== safeRealpath(projectRoot)) {
50
75
  return null;
51
76
  }
52
77
  return info;
@@ -28,17 +28,16 @@ export async function findRequestFile(projectRoot, role, rid) {
28
28
  const artifact = await showRequestArtifact({ projectRoot, role: role, requestId: rid });
29
29
  if (artifact === null)
30
30
  return null;
31
- // Slice 2026-06-28-code-mode-bypass-fix (defect #3): the legacy
32
- // `showRequestArtifact` returns the FULL SCOPE (`_runtime/<sid>`)
33
- // as `sessionId`, not just the trailing id segment. The canonical
34
- // evidence lookup needs only the bare id (`.peaks/_runtime/change/<id>/`).
35
- // When the scope starts with `_runtime/`, strip that prefix so the
36
- // path resolver builds the right canonical location.
37
- let sessionId = artifact.sessionId;
38
- if (sessionId.startsWith('_runtime/') || sessionId.startsWith('_runtime\\')) {
39
- sessionId = sessionId.replace(/^_runtime[\\/]/, '');
40
- }
41
- return { path: artifact.path, content: artifact.content, sessionId };
31
+ // Slice 2026-06-28-code-mode-bypass-fix (defect #3) used to strip a
32
+ // `_runtime/` prefix here, because `showRequestArtifact` then returned the
33
+ // FULL SCOPE (`_runtime/<sid>`) as `sessionId`. Repair R5 removed that
34
+ // round-trip at its source: `readSummary` now builds the summary from the
35
+ // already-resolved directory and the bare id, so `sessionId` is the bare id
36
+ // and the prefix strip could never fire. It was removed by repair R7 rather
37
+ // than left in place as dead code with a comment asserting a behaviour its
38
+ // callee no longer has — an artifact claiming something the code does not do
39
+ // is the defect class this line of work exists to remove.
40
+ return { path: artifact.path, content: artifact.content, sessionId: artifact.sessionId };
42
41
  }
43
42
  /**
44
43
  * Where the CURRENT contract puts one gate's evidence.
@@ -5,7 +5,7 @@
5
5
  * returns a structured `PipelineVerification` envelope. Type
6
6
  * declarations live in `pipeline-verify-types.ts`; private gate
7
7
  * helpers (`rdGatesForType`, `qaGatesForType`, `extractState`,
8
- * `findRequestFile`, the `_runtime/` prefix strip, the RD/QA handoff
8
+ * `findRequestFile`, the RD/QA handoff
9
9
  * state sets, and the RD/QA evidence path probes) live in
10
10
  * `pipeline-verify-gate-support.ts`. The re-export shim at the
11
11
  * bottom preserves the original public surface so existing import
@@ -5,7 +5,7 @@
5
5
  * returns a structured `PipelineVerification` envelope. Type
6
6
  * declarations live in `pipeline-verify-types.ts`; private gate
7
7
  * helpers (`rdGatesForType`, `qaGatesForType`, `extractState`,
8
- * `findRequestFile`, the `_runtime/` prefix strip, the RD/QA handoff
8
+ * `findRequestFile`, the RD/QA handoff
9
9
  * state sets, and the RD/QA evidence path probes) live in
10
10
  * `pipeline-verify-gate-support.ts`. The re-export shim at the
11
11
  * bottom preserves the original public surface so existing import
@@ -16,7 +16,7 @@
16
16
  import { isRequestType } from '../artifacts/artifact-prerequisites.js';
17
17
  import { readSkipState } from './workflow-state-store.js';
18
18
  import { getSessionIdCanonical } from '../session/session-manager.js';
19
- import { listUnpromotedFeedback } from '../feedback/feedback-promotion-service.js';
19
+ import { listPromotionExempt, listUnpromotedFeedback } from '../feedback/feedback-promotion-service.js';
20
20
  import { QA_COMPLETE_STATES, RD_QA_HANDOFF_STATES, extractState, findRequestFile, qaGatesForType, rdGatesForType, resolveQaEvidencePaths, resolveRdEvidencePaths } from './pipeline-verify-gate-support.js';
21
21
  export async function verifyPipeline(options) {
22
22
  const requestType = isRequestType(options.requestType ?? '') ? options.requestType : 'feature';
@@ -178,10 +178,12 @@ export async function verifyPipeline(options) {
178
178
  }
179
179
  // Slice 002 (v2.15.0) AC-3 — Gate H "feedback-promotion". Scans
180
180
  // `.peaks/memory/*.md` for `metadata.type === 'feedback'` entries
181
- // without a promotion marker (HTML comment or `.promotion.json`
182
- // sidecar). When any unpromoted feedback is found, the gate fails
183
- // and the pipeline does not complete until the user promotes via
184
- // `peaks feedback promote <memory-file> --layer <A|B|C>`.
181
+ // that lack a promotion marker (HTML comment or `.promotion.json`
182
+ // sidecar) OR whose marker is not backed by the layer's artifact
183
+ // (rid 2026-09-14-gate-h-promotion). When any such feedback is
184
+ // found, the gate fails and the pipeline does not complete until the
185
+ // user promotes via `peaks feedback promote <memory-file> --layer
186
+ // <A|B|C>` — which now produces the artifact, not just the marker.
185
187
  //
186
188
  // The scan is intentionally non-throwing — a missing or unreadable
187
189
  // memory dir is treated as "no feedback found, gate passes" so
@@ -196,14 +198,25 @@ export async function verifyPipeline(options) {
196
198
  ];
197
199
  try {
198
200
  const unpromoted = listUnpromotedFeedback({ projectRoot: options.projectRoot });
201
+ // rid 2026-09-14-gate-h-promotion (classify slice): memories that declare
202
+ // themselves out of the gate are reported, never dropped. An exemption the
203
+ // gate does not show would be indistinguishable from a fixed violation.
204
+ const exempt = listPromotionExempt({ projectRoot: options.projectRoot });
205
+ const exemptNote = exempt.length === 0
206
+ ? ''
207
+ : `; ${exempt.length} declared not-to-be-promoted: ${exempt.map((e) => `${e.name} (${e.code})`).join('; ')}`;
199
208
  if (unpromoted.length === 0) {
200
209
  feedbackGates[0].passed = true;
201
- feedbackGates[0].detail = `0 unpromoted feedback memories in .peaks/memory/`;
210
+ feedbackGates[0].detail = `0 unpromoted feedback memories in .peaks/memory/${exemptNote}`;
202
211
  }
203
212
  else {
204
- feedbackGates[0].detail = `${unpromoted.length} unpromoted feedback memor${unpromoted.length === 1 ? 'y' : 'ies'}: ${unpromoted.map((u) => u.name).join(', ')}`;
205
- violations.push(`Gate H feedback-promotion FAILED: ${unpromoted.length} feedback memor${unpromoted.length === 1 ? 'y is' : 'ies are'} not yet promoted to an enforcement layer (${unpromoted.map((u) => u.name).join(', ')}). Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each. See sops/feedback-promotion-sop.md.`);
206
- nextActions.push(`Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each unpromoted feedback memory to satisfy Gate H.`);
213
+ // rid 2026-09-14-gate-h-promotion: the gate no longer passes on a marker
214
+ // alone. `listUnpromotedFeedback` now also reports markers whose layer
215
+ // artifact is absent, so `unpromoted` mixes "never promoted" with
216
+ // "promoted on paper only" — the reason on each entry says which.
217
+ feedbackGates[0].detail = `${unpromoted.length} feedback memor${unpromoted.length === 1 ? 'y' : 'ies'} without a backed promotion: ${unpromoted.map((u) => `${u.name} (${u.reason})`).join('; ')}${exemptNote}`;
218
+ violations.push(`Gate H feedback-promotion FAILED: ${unpromoted.length} feedback memor${unpromoted.length === 1 ? 'y is' : 'ies are'} not yet promoted to an enforcement layer with a real artifact (${unpromoted.map((u) => u.name).join(', ')})${exemptNote}. A marker alone does not count: layer A needs a registered SOP manifest, layer B a hook command that runs something named after the rule in .peaks/.claude-settings-template.json, layer C a hard-floor category in src/services/code/mode-gate.ts. Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each and read what it reports. See sops/feedback-promotion-sop.md.`);
219
+ nextActions.push(`Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each feedback memory without a backed promotion to satisfy Gate H.`);
207
220
  }
208
221
  }
209
222
  catch {
@@ -50,9 +50,11 @@ export type PipelineVerification = {
50
50
  * Slice 002 (v2.15.0) AC-3: Gate H "feedback-promotion". Always
51
51
  * present (single-element array). Evaluates whether every
52
52
  * `metadata.type === 'feedback'` memory in `.peaks/memory/`
53
- * carries a promotion marker (comment OR sidecar). Failures
54
- * block the `complete` verdict via the `gateH` field below; the
55
- * pipeline only completes when every gate in this array passes.
53
+ * carries a promotion marker (comment OR sidecar) AND whether the
54
+ * layer that marker claims is actually backed by its artifact
55
+ * (rid 2026-09-14-gate-h-promotion). Failures block the `complete`
56
+ * verdict via the `gateH` field below; the pipeline only completes
57
+ * when every gate in this array passes.
56
58
  */
57
59
  feedbackPhase?: {
58
60
  gates: PipelineGate[];