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.
- package/CHANGELOG.md +44 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/agents/karpathy-reviewer.md +11 -10
- package/dist/cli/cli-helpers.d.ts +34 -0
- package/dist/cli/cli-helpers.js +57 -0
- package/dist/cli/commands/code-job-shape-commands.js +8 -0
- package/dist/cli/commands/code-runtime-commands.js +48 -8
- package/dist/cli/commands/compact-command.js +110 -0
- package/dist/cli/commands/config-commands.js +15 -9
- package/dist/cli/commands/dashboard-long-run.js +6 -0
- package/dist/cli/commands/dispatch-commands.js +11 -1
- package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
- package/dist/cli/commands/feedback-commands.d.ts +11 -7
- package/dist/cli/commands/feedback-commands.js +49 -17
- package/dist/cli/commands/final-review-commands.js +12 -0
- package/dist/cli/commands/hooks-commands.js +4 -4
- package/dist/cli/commands/job-commands.js +8 -0
- package/dist/cli/commands/loop-eval-commands.js +31 -0
- package/dist/cli/commands/perf-audit-commands.js +2 -0
- package/dist/cli/commands/playwright-commands.js +12 -0
- package/dist/cli/commands/prd-commands.js +1 -1
- package/dist/cli/commands/qa-commands.js +22 -0
- package/dist/cli/commands/request-commands.js +8 -0
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/security-audit-commands.js +2 -0
- package/dist/cli/commands/slice-integrate-commands.js +22 -0
- package/dist/cli/commands/statusline-commands.js +44 -4
- package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
- package/dist/cli/commands/sub-agent/detached.js +47 -22
- package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
- package/dist/cli/commands/verdict-aggregate-command.js +95 -13
- package/dist/cli/commands/workflow-commands.js +1 -1
- package/dist/cli/index.js +5 -45
- package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
- package/dist/services/artifacts/artifact-prerequisites.js +140 -65
- package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
- package/dist/services/artifacts/request-artifact-service.js +77 -46
- package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
- package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
- package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
- package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
- package/dist/services/audit-independent/perf-audit-service.js +27 -5
- package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
- package/dist/services/audit-independent/security-audit-service.js +28 -6
- package/dist/services/code/auto-compact-lifecycle.d.ts +194 -0
- package/dist/services/code/auto-compact-lifecycle.js +229 -11
- package/dist/services/code/auto-compact-orchestrator.js +118 -7
- package/dist/services/code/compact-event-settle.d.ts +134 -0
- package/dist/services/code/compact-event-settle.js +240 -0
- package/dist/services/compact-history/compact-history-service.d.ts +14 -0
- package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
- package/dist/services/config/config-restore.d.ts +12 -1
- package/dist/services/config/config-restore.js +35 -4
- package/dist/services/config/config-rollback.js +6 -1
- package/dist/services/context/auto-compact-types.d.ts +20 -2
- package/dist/services/context/harness-context-witness.d.ts +310 -0
- package/dist/services/context/harness-context-witness.js +606 -0
- package/dist/services/evidence/evidence-generator.js +86 -49
- package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
- package/dist/services/feedback/feedback-promotion-service.js +341 -20
- package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
- package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
- package/dist/services/final-review/final-review-service.d.ts +9 -0
- package/dist/services/final-review/final-review-service.js +36 -12
- package/dist/services/ide/ide-registry.d.ts +19 -0
- package/dist/services/ide/ide-registry.js +21 -0
- package/dist/services/job/job-progress-store.js +18 -3
- package/dist/services/job/job-state-store.js +7 -0
- package/dist/services/observability/jsonl-store.d.ts +19 -0
- package/dist/services/observability/jsonl-store.js +27 -2
- package/dist/services/observability/observability-service.d.ts +10 -3
- package/dist/services/observability/observability-service.js +16 -3
- package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
- package/dist/services/prd/handoff-auto-regen.js +31 -27
- package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
- package/dist/services/prd/handoff-frontmatter.js +75 -0
- package/dist/services/prd/handoff-service.d.ts +41 -2
- package/dist/services/prd/handoff-service.js +124 -8
- package/dist/services/prd/handoff-types.d.ts +3 -2
- package/dist/services/prd/handoff-types.js +3 -2
- package/dist/services/qa/qa-business-review-state.js +23 -0
- package/dist/services/sc/sc-service.d.ts +8 -0
- package/dist/services/sc/sc-service.js +8 -1
- package/dist/services/scan/karpathy-service.js +2 -2
- package/dist/services/session/getSessionDir.d.ts +33 -0
- package/dist/services/session/getSessionDir.js +60 -0
- package/dist/services/session/session-checkpoint-service.js +8 -0
- package/dist/services/skill/resume-detector.js +29 -11
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
- package/dist/services/skills/hooks-settings-service.js +14 -4
- package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
- package/dist/services/skills/session-start-hook-constants.js +45 -0
- package/dist/services/skills/skill-statusline-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +29 -11
- package/dist/services/slice/slice-review-state.js +23 -0
- package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
- package/dist/services/workflow/pipeline-verify-gate-support.js +221 -103
- package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
- package/dist/services/workflow/pipeline-verify-service.js +47 -33
- package/dist/services/workflow/pipeline-verify-types.d.ts +15 -6
- package/dist/services/workspace/claude-settings-template.d.ts +56 -8
- package/dist/services/workspace/claude-settings-template.js +98 -20
- package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
- package/dist/shared/runtime-root.d.ts +73 -0
- package/dist/shared/runtime-root.js +77 -0
- package/package.json +6 -6
- package/skills/bee/peaks-prd/SKILL.md +7 -5
- package/skills/bee/peaks-qa/SKILL.md +5 -5
- package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
- package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
- package/skills/bee/peaks-rd/SKILL.md +8 -6
- package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
- package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
- package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
- package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
- package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
- package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
- package/skills/peaks-code/SKILL.md +1 -1
- package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
- package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
- package/skills/peaks-code/references/resume-detection.md +13 -7
- package/skills/peaks-code/references/runbook.md +3 -2
- package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
- package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
if (!existsSync(
|
|
201
|
-
missing.push(
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 = {
|
|
@@ -8,6 +8,7 @@ import { isDirectory } from 'peaks-loop-shared/fs';
|
|
|
8
8
|
// is no longer read. Path-safety helpers now live at
|
|
9
9
|
// `shared/path-safety.ts` if this module ever needs them.
|
|
10
10
|
import { verifyPipeline } from '../workflow/pipeline-verify-service.js';
|
|
11
|
+
import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
|
|
11
12
|
import { findMockViolations } from '../audit/enforcers/mock-placement.js';
|
|
12
13
|
import { runRedLinesAudit } from '../audit/red-lines-service.js';
|
|
13
14
|
/**
|
|
@@ -239,20 +240,29 @@ async function runUnitTests(projectRoot, runTests) {
|
|
|
239
240
|
}
|
|
240
241
|
};
|
|
241
242
|
}
|
|
243
|
+
// Slice `2026-09-14-audit-artifact-rid-scoping`: the evidence filenames
|
|
244
|
+
// carry the rid now. Candidate order is canonical-first, then the
|
|
245
|
+
// back-compat tiers — the same order `artifact-prerequisites.ts` resolves
|
|
246
|
+
// in, so this boundary gate and the transition gate agree on which file is
|
|
247
|
+
// a slice's evidence. `<rid>` is substituted at probe time.
|
|
242
248
|
const REVIEW_FILES = [
|
|
243
|
-
{
|
|
249
|
+
{
|
|
250
|
+
name: 'code-review',
|
|
251
|
+
paths: ['rd/code-review-<rid>.md', 'rd/code-review.md'],
|
|
252
|
+
label: 'code-review'
|
|
253
|
+
},
|
|
244
254
|
// v2.12.0 collapse: security + perf moved to standalone audit skills.
|
|
245
|
-
// `slice check` accepts
|
|
246
|
-
//
|
|
247
|
-
// (v2.13.0 hard-deletes the legacy paths — see CHANGELOG [2.12.0]).
|
|
255
|
+
// `slice check` accepts the rid-scoped audit path, the bare v2.12.0 path
|
|
256
|
+
// OR the v2.11.x legacy path during the 1-minor-release back-compat
|
|
257
|
+
// window (v2.13.0 hard-deletes the legacy paths — see CHANGELOG [2.12.0]).
|
|
248
258
|
{
|
|
249
259
|
name: 'security-review',
|
|
250
|
-
paths: ['audit/security.md', 'rd/security-review.md'],
|
|
260
|
+
paths: ['audit/security-<rid>.md', 'audit/security.md', 'rd/security-review.md'],
|
|
251
261
|
label: 'security-review'
|
|
252
262
|
},
|
|
253
263
|
{
|
|
254
264
|
name: 'perf-baseline',
|
|
255
|
-
paths: ['audit/perf.md', 'rd/perf-baseline.md'],
|
|
265
|
+
paths: ['audit/perf-<rid>.md', 'audit/perf.md', 'rd/perf-baseline.md'],
|
|
256
266
|
label: 'perf-baseline'
|
|
257
267
|
}
|
|
258
268
|
];
|
|
@@ -284,11 +294,11 @@ async function runReviewFanout(projectRoot, rid, refresh) {
|
|
|
284
294
|
const found = [];
|
|
285
295
|
for (const review of REVIEW_FILES) {
|
|
286
296
|
let hit = null;
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
const candidates =
|
|
297
|
+
// Back-compat: each entry lists multiple candidate paths. First hit (in
|
|
298
|
+
// declared order) wins; canonical paths come first so a current slice
|
|
299
|
+
// preferentially reports its own rid-scoped evidence even when a legacy
|
|
300
|
+
// file is also present during migration.
|
|
301
|
+
const candidates = review.paths.map((candidate) => candidate.replace('<rid>', rid));
|
|
292
302
|
for (const candidate of candidates) {
|
|
293
303
|
for (const scope of scopes) {
|
|
294
304
|
const abs = join(projectRoot, '.peaks', scope, candidate);
|
|
@@ -367,6 +377,14 @@ export async function sliceCheck(options) {
|
|
|
367
377
|
throw new Error('No --rid supplied. Pass --rid <id> on the CLI to identify which slice to check.');
|
|
368
378
|
}
|
|
369
379
|
const rid = options.rid;
|
|
380
|
+
// The rid becomes a filename in `runReviewFanout`'s candidate list and a
|
|
381
|
+
// directory segment in its `scopes` — both joined onto the project root. The
|
|
382
|
+
// rid is the repo's own request-id shape, and `request-artifact-service.ts`
|
|
383
|
+
// already refuses anything else; apply the same guard here rather than
|
|
384
|
+
// probing paths a hostile `--rid` steers.
|
|
385
|
+
if (!REQUEST_ID_PATTERN.test(rid)) {
|
|
386
|
+
throw new Error(`Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`);
|
|
387
|
+
}
|
|
370
388
|
const totalStart = Date.now();
|
|
371
389
|
const stages = [];
|
|
372
390
|
let unitTestsRunMode = 'skipped';
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
16
16
|
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { isUnsafePathInput } from '../../shared/path-safety.js';
|
|
18
|
+
import { SLICE_ID_PATTERN } from '../sc/sc-service.js';
|
|
17
19
|
/** The 5 default review items per slice (the 12 Gaps memory checklist). */
|
|
18
20
|
export const DEFAULT_REVIEW_ITEMS = [
|
|
19
21
|
{ id: 'business-match', question: '这个 slice 做完,业务流程对吗?(跟产品最初给的需求匹配)' },
|
|
@@ -32,9 +34,30 @@ export function buildEmptySliceReview(sliceId, sessionId, now = new Date()) {
|
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
export function getReviewDir(projectRoot, sessionId) {
|
|
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`.
|
|
48
|
+
if (isUnsafePathInput(sessionId)) {
|
|
49
|
+
throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
|
|
50
|
+
}
|
|
35
51
|
return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'slice-reviews');
|
|
36
52
|
}
|
|
37
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
|
+
}
|
|
38
61
|
return join(getReviewDir(projectRoot, sessionId), `${sliceId}.json`);
|
|
39
62
|
}
|
|
40
63
|
export function readSliceReview(projectRoot, sessionId, sliceId) {
|