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
@@ -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,68 @@
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 { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
27
31
  /** Required schema version for new handoffs. */
28
32
  const HANDOFF_SCHEMA_VERSION = '2';
29
33
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
30
34
  export function sha256OfBody(body) {
31
35
  return createHash('sha256').update(body, 'utf8').digest('hex');
32
36
  }
37
+ /**
38
+ * The canonical capsule path for ONE SLICE, relative to the project root.
39
+ *
40
+ * Slice `2026-09-14-prd-capsule-rid-scoping`: the capsule used to be one slot
41
+ * per SESSION (`prd/handoff.md`), so the second slice's handoff silently
42
+ * overwrote the first slice's — and `AUDIT_REQUIRES_HANDOFF` stayed green
43
+ * because it never checked WHOSE rid the file named. Measured on
44
+ * `2026-09-13-session-21878f`: a four-slice job passed that prerequisite on a
45
+ * capsule left by a different line of work.
46
+ *
47
+ * This is the WRITE target, so it always carries the rid — a consumer-side
48
+ * fallback here would leave a rid-scoped requirement with a bare-name writer,
49
+ * which is the defect shape this slice exists to remove. Readers that must
50
+ * tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
51
+ */
52
+ export function handoffRelativePath(sessionId, requestId) {
53
+ return join('.peaks', '_runtime', sessionId, 'prd', `handoff-${requestId}.md`);
54
+ }
55
+ /**
56
+ * The capsule a CONSUMER should read for (session, requestId): the rid-scoped
57
+ * path when it is on disk, else the pre-rid-scoping bare name. Returns null
58
+ * when neither exists.
59
+ *
60
+ * Three sessions on disk still hold only the bare file
61
+ * (`2026-09-06-session-a87ca4`, `2026-09-12-session-e37ef0`,
62
+ * `2026-09-13-session-21878f`), so the legacy tier has to keep resolving for
63
+ * the gate and for every reader below.
64
+ *
65
+ * `requestId` is optional because the detect-only audit surface reaches its
66
+ * service without one. Such a caller can name only the bare path: a session
67
+ * holding nothing but rid-scoped capsules reports missing rather than picking
68
+ * among its siblings' capsules, which would re-open the cross-slice mix-up
69
+ * this scoping exists to close (fail closed, not "some capsule is there").
70
+ */
71
+ export function resolveHandoffPath(opts) {
72
+ const candidates = [
73
+ ...(opts.requestId !== undefined ? [handoffRelativePath(opts.sessionId, opts.requestId)] : []),
74
+ join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md')
75
+ ];
76
+ for (const relative of candidates) {
77
+ const absolute = join(opts.projectRoot, relative);
78
+ if (existsSync(absolute))
79
+ return absolute;
80
+ }
81
+ return null;
82
+ }
33
83
  /** Pure: produce a Handoff with the frontmatter populated. Hash is
34
84
  * computed here; callers MUST NOT pre-populate `handoffHash`. */
35
85
  export function initHandoff(opts) {
36
86
  const handoffPath = opts.handoffPath ??
37
- join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md');
87
+ handoffRelativePath(opts.sessionId, opts.requestId);
38
88
  const handoffHash = sha256OfBody(opts.body);
39
89
  const frontmatter = {
40
90
  requestId: opts.requestId,
@@ -146,8 +196,11 @@ function parseHandoffContent(content) {
146
196
  };
147
197
  }
148
198
  function serializeHandoff(handoff) {
149
- const yamlStr = stringifyYaml(handoff.frontmatter).trimEnd();
150
- return `---\n${yamlStr}\n---\n${handoff.body}`;
199
+ // The one canonical frontmatter rendering, shared with
200
+ // `handoff-auto-regen.ts`. `yaml.stringify` used to render this block and
201
+ // emitted `schemaVersion: "2"` + a bare `handoffHash:`, which the
202
+ // `AUDIT_REQUIRES_HANDOFF` gate and both audit loaders all reject.
203
+ return `${serializeHandoffFrontmatter(handoff.frontmatter)}${handoff.body}`;
151
204
  }
152
205
  /**
153
206
  * N1: accept BOTH shapes of `schemaVersion` — the string `'2'` and the bare
@@ -161,13 +214,33 @@ function serializeHandoff(handoff) {
161
214
  * readable handoff was satisfied by a handoff the parser would not read, and
162
215
  * `peaks prd handoff verify` exited 1 on a healthy file.
163
216
  *
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.
217
+ * Slice `2026-09-14-handoff-writer-gate-divergence` then fixed the other half:
218
+ * the writer no longer emits the quoted form at all (see
219
+ * `handoff-frontmatter.ts`). This tolerance stays because handoffs already on
220
+ * disk were written by the old writer and by hand; the writer fix must not
221
+ * retroactively make them unreadable.
167
222
  */
168
223
  function isSchemaVersion2(value) {
169
224
  return value === HANDOFF_SCHEMA_VERSION || value === 2;
170
225
  }
226
+ /**
227
+ * The READER's shape check — deliberately looser than `verifyHandoff`:
228
+ * `handoffHash` must be a string, and nothing about its VALUE is validated here.
229
+ *
230
+ * The accepted-but-unverifiable shape, stated rather than left to be
231
+ * discovered: a capsule carrying `handoffHash: sha256:<hex>` — the form the
232
+ * pre-fix writer and this session's hand-corrected capsules use — is READ by
233
+ * `readHandoff` and can NEVER pass `verifyHandoff`. The verifier compares the
234
+ * value to `sha256OfBody(body)` byte for byte (`:127-135`) and no prefix
235
+ * normalization exists anywhere in this module, so the leading `sha256:`
236
+ * guarantees `hash-mismatch`. ONLY the bare-hex form verifies, which is what
237
+ * every writer now emits (`handoff-frontmatter.ts`).
238
+ *
239
+ * So `readHandoff` succeeding is NOT evidence that a capsule is verifiable —
240
+ * the tolerance above exists so old capsules stay READABLE, not so they become
241
+ * acceptable. Request §三 bullet 3 asked for exactly this confirmation
242
+ * (rid `2026-09-14-handoff-writer-gate-divergence`).
243
+ */
171
244
  function isHandoffFrontmatter(value) {
172
245
  if (!value || typeof value !== 'object')
173
246
  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,7 @@
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';
19
20
  /** 6-item business checklist (12 Gaps QA perspective). */
20
21
  export const QA_BUSINESS_ITEMS = [
21
22
  { id: 'business-flow', question: '这个功能"用起来"对吗?(业务流程顺不顺,操作路径是否反人类,跟现有系统交互有没有断层)' },
@@ -36,6 +37,14 @@ export function buildEmptyQaReview(requestId, sessionId, now = new Date()) {
36
37
  };
37
38
  }
38
39
  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).
45
+ if (isUnsafePathInput(sessionId)) {
46
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
47
+ }
39
48
  return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'qa-business-reviews');
40
49
  }
41
50
  export function getQaReviewPath(projectRoot, sessionId, requestId) {
@@ -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
  }
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
13
  import { join, sep } from 'node:path';
14
14
  import { emitObservabilityEvent } from '../observability/observability-service.js';
15
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
15
16
  const CHECKPOINTS_DIR = 'checkpoints';
16
17
  const CHECKPOINT_FILENAME_EXT = '.json';
17
18
  const MAX_CHECKPOINTS = 10;
@@ -76,6 +77,13 @@ function pruneOldest(dir) {
76
77
  return removed;
77
78
  }
78
79
  export function writeCheckpoint(projectRoot, options) {
80
+ // Sid axis, placed before the first read and the first `mkdir`. Both
81
+ // `peaks session checkpoint --session-id` and `peaks compact force
82
+ // --session-id` reach this same function, so one guard closes both measured
83
+ // surfaces — they were never two joins.
84
+ if (isUnsafePathInput(options.sessionId)) {
85
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
86
+ }
79
87
  const now = (options.now ?? (() => new Date()))();
80
88
  const createdAt = now.toISOString();
81
89
  const lastActivity = readSessionLastActivity(projectRoot, options.sessionId) || createdAt;
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
+ import { readArtifactState } from '../artifacts/request-artifact-state-helpers.js';
35
36
  const MID_IMPL_RD_STATES = new Set([
36
37
  'spec-locked',
37
38
  'implemented',
@@ -192,13 +193,21 @@ function classifyTerminalGates(sessionDir, ctx) {
192
193
  // RD qa-handoff → deepest gate is C. If the review artifacts are
193
194
  // missing the state is inconsistent; fall back to rd-review-fanout.
194
195
  if (ctx.primaryRd !== null && ctx.primaryRd.state === 'qa-handoff') {
195
- const codeReviewPath = join(sessionDir, 'rd', 'code-review.md');
196
- const securityReviewPath = join(sessionDir, 'rd', 'security-review.md');
196
+ // Slice `2026-09-14-audit-artifact-rid-scoping`: the fan-out evidence
197
+ // filenames carry the rid. Probe the canonical rid-scoped name first and
198
+ // the pre-rid names behind it — the same order the transition gate
199
+ // resolves in, so an inconsistent-looking slice here means the fan-out
200
+ // never ran, not that it wrote to a name this reader does not know.
201
+ const rid = ridOf(ctx.primaryRd.filename);
197
202
  const missing = [];
198
- if (!existsSync(codeReviewPath))
199
- missing.push('rd/code-review.md');
200
- if (!existsSync(securityReviewPath))
201
- missing.push('rd/security-review.md');
203
+ const codeReviewCandidates = [`rd/code-review-${rid}.md`, 'rd/code-review.md'];
204
+ const securityCandidates = [`audit/security-${rid}.md`, 'audit/security.md', 'rd/security-review.md'];
205
+ if (!codeReviewCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
206
+ missing.push(codeReviewCandidates[0]);
207
+ }
208
+ if (!securityCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
209
+ missing.push(securityCandidates[0]);
210
+ }
202
211
  if (missing.length > 0) {
203
212
  return {
204
213
  kind: 'resume',
@@ -317,12 +326,21 @@ function readRequestStates(sessionDir, role) {
317
326
  };
318
327
  });
319
328
  }
329
+ /** Empty string (not 'unknown') when the artifact has no state line — `primaryPrd.state.length > 0` below depends on that. */
320
330
  function extractState(content) {
321
- const match = /^-\s*state:\s*(\S+)|^state:\s*(\S+)/m.exec(content);
322
- if (match === null)
323
- return '';
324
- const captured = match[1] ?? match[2] ?? '';
325
- return captured.trim();
331
+ return readArtifactState(content) ?? '';
332
+ }
333
+ /**
334
+ * The `<rid>` embedded in a request filename: drop the `.md` suffix and the
335
+ * zero-padded `NNN-` prefix `request init` writes.
336
+ *
337
+ * Uses the same `^0\d{2}-` rule as the request loader
338
+ * (`request-artifact-service.ts`: "Only strip 3-digit zero-padded prefixes").
339
+ * A looser `^\d+-` would eat the leading year of every real rid, which is
340
+ * itself date-shaped (`2026-09-14-<slug>`).
341
+ */
342
+ function ridOf(filename) {
343
+ return filename.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
326
344
  }
327
345
  function hasAbandonedTransitionNote(content) {
328
346
  return /user-requested-abandon/.test(content);
@@ -39,6 +39,12 @@ interface ResolvedHookSpec {
39
39
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
40
40
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
41
41
  * everywhere else.
42
+ *
43
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
44
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
45
+ * mechanism is believed absent there — see the block comment in
46
+ * `resolveHookEntries` for what is and is not established, and for why adding
47
+ * the pin in place would break every non-Windows teammate.
42
48
  */
43
49
  export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
44
50
  /**
@@ -8,7 +8,7 @@
8
8
  * type contract re-exported by the caller.
9
9
  */
10
10
  import { getAdapter } from '../ide/ide-registry.js';
11
- import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_POST_COMPACT_REINJECT_COMMAND, HOOK_POST_COMPACT_REINJECT_EVENT, HOOK_POST_COMPACT_REINJECT_MATCHER, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
11
+ import { HOOK_COMPACT_SETTLE_COMMAND, HOOK_COMPACT_SETTLE_EVENT, HOOK_COMPACT_SETTLE_MATCHER, HOOK_COMPACT_SETTLE_SENTINEL, HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_POST_COMPACT_REINJECT_COMMAND, HOOK_POST_COMPACT_REINJECT_EVENT, HOOK_POST_COMPACT_REINJECT_MATCHER, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
12
12
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
13
13
  export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
14
14
  /**
@@ -20,6 +20,12 @@ export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
20
20
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
21
21
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
22
22
  * everywhere else.
23
+ *
24
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
25
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
26
+ * mechanism is believed absent there — see the block comment in
27
+ * `resolveHookEntries` for what is and is not established, and for why adding
28
+ * the pin in place would break every non-Windows teammate.
23
29
  */
24
30
  export function resolveHookShell(platform = process.platform) {
25
31
  return platform === 'win32' ? 'powershell' : undefined;
@@ -112,6 +118,37 @@ export function resolveHookEntries(ide, _skipProgress = false) {
112
118
  ...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
113
119
  }
114
120
  ];
121
+ // ── Why the three SessionStart entries below carry NO `shell` pin ─────────
122
+ //
123
+ // The gate-enforce entry above is shell-pinned on Windows (see
124
+ // `resolveHookShell`) because Claude Code runs a shell-form hook command
125
+ // through a shell that defaults to bash — Git Bash / MSYS2 on Windows — and
126
+ // MSYS2 bash force-allocates its own console window. That reason is a
127
+ // property of the hook RUNNER's shell resolution, not of the `PreToolUse`
128
+ // event: nothing in it exempts `SessionStart`, so the same command-form
129
+ // entry on this event goes through the same shell. The pin was applied only
130
+ // to the `Bash`-matcher handlers because those were the ones the reporter
131
+ // could see (they run on EVERY Bash tool call); these three run once per
132
+ // session, so a window here — if there is one — is a single flash rather
133
+ // than a per-tool-call nuisance. That is a difference in frequency, not
134
+ // evidence that the window does not appear, and no A/B measurement on this
135
+ // event exists.
136
+ //
137
+ // The pin is nonetheless NOT applied here, and adding it would be a defect
138
+ // rather than a fix: `shell: "powershell"` resolves to `pwsh`, these entries
139
+ // are written to the COMMITTED `.claude/settings.json`, and a macOS / Linux
140
+ // teammate reading that file has no `pwsh` — the exact cross-platform damage
141
+ // the machine-local split exists to prevent (see the gate-enforce entry's
142
+ // `machineLocal` flag, and commit 4637baa8's rationale).
143
+ //
144
+ // A correct fix therefore has to move these three entries to the
145
+ // machine-local file as well, which changes where a fresh clone gets its
146
+ // SessionStart hooks: today they arrive with the repository, after such a
147
+ // change they would require `peaks hooks install` / `peaks workspace init`.
148
+ // That is a product-shape decision, not a leftover; it is recorded here so
149
+ // that whoever makes it starts from the reason the pin is absent instead of
150
+ // re-deriving it — or, worse, adding the pin in place and breaking every
151
+ // non-Windows teammate.
115
152
  if (ide === 'claude-code') {
116
153
  entries.push({
117
154
  sentinel: HOOK_OUTER_CACHE_SENTINEL,
@@ -161,6 +198,24 @@ export function resolveHookEntries(ide, _skipProgress = false) {
161
198
  command: HOOK_POST_COMPACT_REINJECT_COMMAND,
162
199
  event: HOOK_POST_COMPACT_REINJECT_EVENT
163
200
  });
201
+ // rid 2026-09-13-compact-event-settle: the harness's OWN "a compaction
202
+ // completed" event, which is the only signal that settles a compact as a
203
+ // FACT rather than as an inference from a ratio that fell.
204
+ //
205
+ // No `machineLocal` flag, so this lands in the shared, committed
206
+ // `.claude/settings.json` — the same file as the three SessionStart entries
207
+ // above, and NOT the machine-local file the workspace-init materializer
208
+ // owns. That routing IS the answer to "who owns the `hooks` key for this
209
+ // entry": the only writer that could delete it is the one that installed
210
+ // it. See `session-start-hook-constants.ts` for why it also carries no
211
+ // `shell` pin, and `mergeHooksTree` for the second line of defence if it
212
+ // ever moves.
213
+ entries.push({
214
+ sentinel: HOOK_COMPACT_SETTLE_SENTINEL,
215
+ matcher: HOOK_COMPACT_SETTLE_MATCHER,
216
+ command: HOOK_COMPACT_SETTLE_COMMAND,
217
+ event: HOOK_COMPACT_SETTLE_EVENT
218
+ });
164
219
  }
165
220
  return entries;
166
221
  }
@@ -191,7 +246,11 @@ export function resolveLegacySentinels(ide) {
191
246
  // it) exactly like the other two SessionStart entries. Without this the
192
247
  // entry would be unremovable by `peaks hooks uninstall` — the rollback
193
248
  // path T2 requires.
194
- return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL];
249
+ // rid 2026-09-13-compact-event-settle: the PostCompact settle sentinel joins
250
+ // too — same rollback argument as the reinject entry above. A hook that
251
+ // `peaks hooks uninstall` cannot remove is a hook the user cannot get rid
252
+ // of, and this one fires on every compaction.
253
+ return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_COMPACT_SETTLE_SENTINEL];
195
254
  }
196
255
  return base;
197
256
  }
@@ -322,10 +322,20 @@ function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
322
322
  return false;
323
323
  }
324
324
  }
325
- // (b) every desired entry must be on disk.
326
- for (const sentinel of desiredSentinels) {
327
- const has = peaksPresent.some((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(sentinel)));
328
- if (!has)
325
+ // (b) every desired entry must be on disk AND carry the matcher it declares.
326
+ // Presence alone cannot see a WRONG matcher, and a wrong matcher is not
327
+ // cosmetic: `''` and `Bash|Task` route the same command to different
328
+ // tool sets, so the entry is present while the hook never fires on the
329
+ // tools it was installed for. Measured (rid `2026-09-13-compact-event-settle`,
330
+ // residual R2): a `PostCompact` entry hand-corrupted to matcher
331
+ // `auto|manual` survived `peaks hooks install` unchanged, and the
332
+ // installer was structurally unable to repair it.
333
+ // An absent matcher reads as `''` — the form Claude Code takes as "every
334
+ // source" — so a file written before matchers were explicit converges
335
+ // once instead of churning on every install.
336
+ for (const desired of entries.filter((e) => e.event === eventKey)) {
337
+ const onDisk = peaksPresent.find((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(desired.sentinel)));
338
+ if (onDisk === undefined || (onDisk.matcher ?? '') !== desired.matcher)
329
339
  return false;
330
340
  }
331
341
  }
@@ -83,3 +83,48 @@ export declare const HOOK_WORKSPACE_INIT_SENTINEL = "peaks session primer";
83
83
  export declare const HOOK_WORKSPACE_INIT_COMMAND = "peaks session primer --project \"${CLAUDE_PROJECT_DIR}\"";
84
84
  /** SessionStart hook event key (same as outer-cache). */
85
85
  export declare const HOOK_WORKSPACE_INIT_EVENT = "SessionStart";
86
+ /**
87
+ * rid `2026-09-13-compact-event-settle` — the `PostCompact` entry that lets the
88
+ * harness's own event settle a compact, instead of the next `context-now` probe
89
+ * inferring one from a ratio that fell.
90
+ *
91
+ * WHY THIS ENTRY IS NOT A `SessionStart` ONE, despite living in this file: the
92
+ * three entries above all ride `SessionStart` and differ only by matcher. A
93
+ * `PostCompact` hook is a different EVENT that carries the one fact no
94
+ * `SessionStart` payload has — whether the compaction the harness just
95
+ * completed was `auto` or `manual`. That distinction is the whole question
96
+ * ("has this machine ever auto-compacted?"), and without it peaks-loop can only
97
+ * ever see that SOMETHING compacted. See `compact-event-settle.ts` for what the
98
+ * command does with it.
99
+ *
100
+ * WHY THE MATCHER IS THE EMPTY STRING and not the documented `auto|manual`:
101
+ * both trigger values are wanted, so the matcher must filter nothing. An empty
102
+ * matcher is the convention the three `SessionStart` entries already rely on to
103
+ * match every source, and it is the only form that cannot fail SILENTLY — an
104
+ * alternation string is a match-everything pattern under regex semantics but
105
+ * matches NEITHER value under exact-equality semantics, and a hook that never
106
+ * fires looks exactly like a hook with nothing to report.
107
+ */
108
+ export declare const HOOK_COMPACT_SETTLE_SENTINEL = "peaks compact settle";
109
+ /**
110
+ * The settle hook command. `--project "${CLAUDE_PROJECT_DIR}"` is byte-for-byte
111
+ * the shape of the three `SessionStart` entries above — Claude Code's standard
112
+ * project-root convention, resolved strictly on the CLI side (a hook payload is
113
+ * env-driven and must not be trusted as a path).
114
+ *
115
+ * The command prints NOTHING on the hook path and exits 0 for every outcome.
116
+ * `PostCompact`'s stdin/stdout contract is truncated in the retrievable docs,
117
+ * so the safe assumption is the `SessionStart` one — stdout may be added to the
118
+ * model's context. An error message there would be read as a fact. See
119
+ * `compact-event-settle.ts`.
120
+ *
121
+ * No `shell` pin, deliberately: this entry lands in the shared, committed
122
+ * `.claude/settings.json`, and a `powershell` pin there would break every
123
+ * macOS / Linux reader of the file. See `resolveHookEntries`' comment block for
124
+ * the full reason the three `SessionStart` entries are unpinned too.
125
+ */
126
+ export declare const HOOK_COMPACT_SETTLE_COMMAND = "peaks compact settle --project \"${CLAUDE_PROJECT_DIR}\"";
127
+ /** The event this entry rides. Claude Code fires it after a compaction completes. */
128
+ export declare const HOOK_COMPACT_SETTLE_EVENT = "PostCompact";
129
+ /** Matcher: empty = every `trigger` (`auto` and `manual`). See the sentinel doc. */
130
+ export declare const HOOK_COMPACT_SETTLE_MATCHER = "";
@@ -83,3 +83,48 @@ export const HOOK_WORKSPACE_INIT_SENTINEL = 'peaks session primer';
83
83
  export const HOOK_WORKSPACE_INIT_COMMAND = `peaks session primer --project "\${CLAUDE_PROJECT_DIR}"`;
84
84
  /** SessionStart hook event key (same as outer-cache). */
85
85
  export const HOOK_WORKSPACE_INIT_EVENT = 'SessionStart';
86
+ /**
87
+ * rid `2026-09-13-compact-event-settle` — the `PostCompact` entry that lets the
88
+ * harness's own event settle a compact, instead of the next `context-now` probe
89
+ * inferring one from a ratio that fell.
90
+ *
91
+ * WHY THIS ENTRY IS NOT A `SessionStart` ONE, despite living in this file: the
92
+ * three entries above all ride `SessionStart` and differ only by matcher. A
93
+ * `PostCompact` hook is a different EVENT that carries the one fact no
94
+ * `SessionStart` payload has — whether the compaction the harness just
95
+ * completed was `auto` or `manual`. That distinction is the whole question
96
+ * ("has this machine ever auto-compacted?"), and without it peaks-loop can only
97
+ * ever see that SOMETHING compacted. See `compact-event-settle.ts` for what the
98
+ * command does with it.
99
+ *
100
+ * WHY THE MATCHER IS THE EMPTY STRING and not the documented `auto|manual`:
101
+ * both trigger values are wanted, so the matcher must filter nothing. An empty
102
+ * matcher is the convention the three `SessionStart` entries already rely on to
103
+ * match every source, and it is the only form that cannot fail SILENTLY — an
104
+ * alternation string is a match-everything pattern under regex semantics but
105
+ * matches NEITHER value under exact-equality semantics, and a hook that never
106
+ * fires looks exactly like a hook with nothing to report.
107
+ */
108
+ export const HOOK_COMPACT_SETTLE_SENTINEL = 'peaks compact settle';
109
+ /**
110
+ * The settle hook command. `--project "${CLAUDE_PROJECT_DIR}"` is byte-for-byte
111
+ * the shape of the three `SessionStart` entries above — Claude Code's standard
112
+ * project-root convention, resolved strictly on the CLI side (a hook payload is
113
+ * env-driven and must not be trusted as a path).
114
+ *
115
+ * The command prints NOTHING on the hook path and exits 0 for every outcome.
116
+ * `PostCompact`'s stdin/stdout contract is truncated in the retrievable docs,
117
+ * so the safe assumption is the `SessionStart` one — stdout may be added to the
118
+ * model's context. An error message there would be read as a fact. See
119
+ * `compact-event-settle.ts`.
120
+ *
121
+ * No `shell` pin, deliberately: this entry lands in the shared, committed
122
+ * `.claude/settings.json`, and a `powershell` pin there would break every
123
+ * macOS / Linux reader of the file. See `resolveHookEntries`' comment block for
124
+ * the full reason the three `SessionStart` entries are unpinned too.
125
+ */
126
+ export const HOOK_COMPACT_SETTLE_COMMAND = `peaks compact settle --project "\${CLAUDE_PROJECT_DIR}"`;
127
+ /** The event this entry rides. Claude Code fires it after a compaction completes. */
128
+ export const HOOK_COMPACT_SETTLE_EVENT = 'PostCompact';
129
+ /** Matcher: empty = every `trigger` (`auto` and `manual`). See the sentinel doc. */
130
+ export const HOOK_COMPACT_SETTLE_MATCHER = '';
@@ -7,6 +7,20 @@ export type StatusLineStdin = {
7
7
  cwd?: string;
8
8
  session_id?: string;
9
9
  caller_id?: string;
10
+ /**
11
+ * The harness's own context numbers. Declared here because this type IS the
12
+ * documented shape of the payload the harness pipes in; omitting a documented
13
+ * field would make the type lie by omission, and a consumer that reached for
14
+ * it would have to cast. Read (never written) by
15
+ * `harness-context-witness.ts` — see that module for why
16
+ * `context_window_size` is NOT a denominator.
17
+ */
18
+ context_window?: {
19
+ context_window_size?: unknown;
20
+ used_percentage?: unknown;
21
+ remaining_percentage?: unknown;
22
+ current_usage?: Record<string, unknown> | undefined;
23
+ } | undefined;
10
24
  };
11
25
  export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
12
26
  export type StatusLinePresence = {