peaks-loop 4.0.48 → 4.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/compact-command.js +1 -3
  5. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  6. package/dist/cli/commands/feedback-commands.js +49 -17
  7. package/dist/cli/commands/final-review-commands.js +12 -0
  8. package/dist/cli/commands/loop-eval-commands.js +22 -6
  9. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  11. package/dist/services/artifacts/request-artifact-service.js +59 -38
  12. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  13. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  14. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  15. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  16. package/dist/services/code/compact-event-settle.d.ts +20 -8
  17. package/dist/services/code/compact-event-settle.js +21 -0
  18. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  19. package/dist/services/context/auto-compact-types.d.ts +20 -2
  20. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  21. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  22. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  23. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  24. package/dist/services/job/job-progress-store.js +18 -3
  25. package/dist/services/observability/jsonl-store.d.ts +19 -0
  26. package/dist/services/observability/jsonl-store.js +27 -2
  27. package/dist/services/observability/observability-service.d.ts +10 -3
  28. package/dist/services/observability/observability-service.js +16 -3
  29. package/dist/services/prd/handoff-service.js +43 -0
  30. package/dist/services/qa/qa-business-review-state.js +19 -5
  31. package/dist/services/sc/sc-service.d.ts +8 -0
  32. package/dist/services/sc/sc-service.js +8 -1
  33. package/dist/services/session/getSessionDir.d.ts +33 -0
  34. package/dist/services/session/getSessionDir.js +60 -0
  35. package/dist/services/slice/slice-review-state.js +19 -4
  36. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  37. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  38. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  39. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  40. package/dist/shared/runtime-root.d.ts +73 -0
  41. package/dist/shared/runtime-root.js +77 -0
  42. package/package.json +5 -5
@@ -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
  }
@@ -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
+ }
@@ -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) {
@@ -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[];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one seam through which a `.peaks/_runtime` path is built.
3
+ *
4
+ * Slice 2026-09-15 (runtime-path-unrepresentable). Three attempts to *detect*
5
+ * a caller-supplied id reaching a runtime join all failed the same way: the
6
+ * shipped text rule caught 4 of 12 fixture shapes where the name-based
7
+ * predicate it replaced caught 8, and the version that reached 10 of 12 gave
8
+ * back the change that reached 12 because it cost seven findings on live code —
9
+ * two of them structural (a guard helper, and readers that take the guarded
10
+ * value as a parameter). Its measured verdict: reassignment between guard and
11
+ * join, and guard-after-join, are **domination failures inside a single
12
+ * function, invisible to any text key**.
13
+ *
14
+ * So the instrument is retired in favour of a property. The id cannot reach a
15
+ * runtime join unguarded because there is no longer a join that accepts an
16
+ * unguarded string: `RuntimeRoot.join` takes `GuardedSegment`, and a
17
+ * `GuardedSegment` can only be produced by `guardRuntimeSegment`, which throws
18
+ * on the shapes the escapes used. A newly written unguarded join is a type
19
+ * error at authoring time — not a removed guard that some later scan notices.
20
+ *
21
+ * The raw root is not obtainable as a string except through `dir()`, which is
22
+ * named so that a join written from it (`join(root.dir(), id)`) reads at review
23
+ * time as the bypass it is. `dir()` exists because callers legitimately
24
+ * enumerate the root itself; it is not a join.
25
+ */
26
+ declare const RUNTIME_SEGMENT: unique symbol;
27
+ /**
28
+ * A path segment that has been checked as a single safe segment.
29
+ *
30
+ * Unforgeable by construction: the brand is a `unique symbol` that is never
31
+ * exported, so `value as GuardedSegment` outside this module is a type error
32
+ * and a plain `string` is not assignable. `guardRuntimeSegment` is the only
33
+ * producer.
34
+ */
35
+ export type GuardedSegment = string & {
36
+ readonly [RUNTIME_SEGMENT]: true;
37
+ };
38
+ /**
39
+ * Check a caller-supplied string and brand it for use as a runtime path
40
+ * segment. `label` names the id in the error the way the caller knows it
41
+ * ("session id", "project id"), because the throw site is one function away
42
+ * from the caller that supplied it.
43
+ *
44
+ * Rejects exactly the shapes `isUnsafePathInput` rejects: separators, `..`,
45
+ * absolute and drive-prefixed paths, UNC and URL shapes, and empty segments.
46
+ */
47
+ export declare function guardRuntimeSegment(value: string, label: string): GuardedSegment;
48
+ /**
49
+ * The `.peaks/_runtime` root of one project, as a capability rather than a
50
+ * string. `#path` is a private field, so the raw root cannot be read off the
51
+ * object and joined by an unguarded `join()`.
52
+ */
53
+ export declare class RuntimeRoot {
54
+ #private;
55
+ private constructor();
56
+ /** The runtime root of `projectRoot`. */
57
+ static at(projectRoot: string): RuntimeRoot;
58
+ /**
59
+ * Join guarded segments onto the root. At least one segment is required: a
60
+ * zero-argument `join()` would hand back the bare root as a `string`, which
61
+ * is the capability this class exists to withhold.
62
+ */
63
+ join(first: GuardedSegment, ...rest: readonly GuardedSegment[]): string;
64
+ /**
65
+ * The root itself, for READ-only enumeration (`readdir`, `existsSync`) — not
66
+ * for joining. Deliberately a method rather than a property so a bypass is
67
+ * legible at the call site.
68
+ */
69
+ dir(): string;
70
+ }
71
+ /** The runtime root of `projectRoot`. */
72
+ export declare function runtimeRoot(projectRoot: string): RuntimeRoot;
73
+ export {};
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The one seam through which a `.peaks/_runtime` path is built.
3
+ *
4
+ * Slice 2026-09-15 (runtime-path-unrepresentable). Three attempts to *detect*
5
+ * a caller-supplied id reaching a runtime join all failed the same way: the
6
+ * shipped text rule caught 4 of 12 fixture shapes where the name-based
7
+ * predicate it replaced caught 8, and the version that reached 10 of 12 gave
8
+ * back the change that reached 12 because it cost seven findings on live code —
9
+ * two of them structural (a guard helper, and readers that take the guarded
10
+ * value as a parameter). Its measured verdict: reassignment between guard and
11
+ * join, and guard-after-join, are **domination failures inside a single
12
+ * function, invisible to any text key**.
13
+ *
14
+ * So the instrument is retired in favour of a property. The id cannot reach a
15
+ * runtime join unguarded because there is no longer a join that accepts an
16
+ * unguarded string: `RuntimeRoot.join` takes `GuardedSegment`, and a
17
+ * `GuardedSegment` can only be produced by `guardRuntimeSegment`, which throws
18
+ * on the shapes the escapes used. A newly written unguarded join is a type
19
+ * error at authoring time — not a removed guard that some later scan notices.
20
+ *
21
+ * The raw root is not obtainable as a string except through `dir()`, which is
22
+ * named so that a join written from it (`join(root.dir(), id)`) reads at review
23
+ * time as the bypass it is. `dir()` exists because callers legitimately
24
+ * enumerate the root itself; it is not a join.
25
+ */
26
+ import { join } from 'node:path';
27
+ import { isUnsafePathInput } from './path-safety.js';
28
+ /**
29
+ * Check a caller-supplied string and brand it for use as a runtime path
30
+ * segment. `label` names the id in the error the way the caller knows it
31
+ * ("session id", "project id"), because the throw site is one function away
32
+ * from the caller that supplied it.
33
+ *
34
+ * Rejects exactly the shapes `isUnsafePathInput` rejects: separators, `..`,
35
+ * absolute and drive-prefixed paths, UNC and URL shapes, and empty segments.
36
+ */
37
+ export function guardRuntimeSegment(value, label) {
38
+ if (isUnsafePathInput(value)) {
39
+ throw new Error(`Invalid ${label}: ${value} (must be a single path segment)`);
40
+ }
41
+ return value;
42
+ }
43
+ /**
44
+ * The `.peaks/_runtime` root of one project, as a capability rather than a
45
+ * string. `#path` is a private field, so the raw root cannot be read off the
46
+ * object and joined by an unguarded `join()`.
47
+ */
48
+ export class RuntimeRoot {
49
+ #path;
50
+ constructor(path) {
51
+ this.#path = path;
52
+ }
53
+ /** The runtime root of `projectRoot`. */
54
+ static at(projectRoot) {
55
+ return new RuntimeRoot(join(projectRoot, '.peaks', '_runtime'));
56
+ }
57
+ /**
58
+ * Join guarded segments onto the root. At least one segment is required: a
59
+ * zero-argument `join()` would hand back the bare root as a `string`, which
60
+ * is the capability this class exists to withhold.
61
+ */
62
+ join(first, ...rest) {
63
+ return join(this.#path, first, ...rest);
64
+ }
65
+ /**
66
+ * The root itself, for READ-only enumeration (`readdir`, `existsSync`) — not
67
+ * for joining. Deliberately a method rather than a property so a bypass is
68
+ * legible at the call site.
69
+ */
70
+ dir() {
71
+ return this.#path;
72
+ }
73
+ }
74
+ /** The runtime root of `projectRoot`. */
75
+ export function runtimeRoot(projectRoot) {
76
+ return RuntimeRoot.at(projectRoot);
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.48",
3
+ "version": "4.0.49",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -102,10 +102,10 @@
102
102
  "picomatch": "4.0.4",
103
103
  "yaml": "^2.9.0",
104
104
  "zod": "^4.4.3",
105
- "peaks-loop-internal-runtime": "0.0.33",
106
- "peaks-loop-shared-channel": "0.0.50",
107
- "peaks-loop-shared": "0.0.82",
108
- "peaks-loop-mut": "0.1.46"
105
+ "peaks-loop-internal-runtime": "0.0.34",
106
+ "peaks-loop-mut": "0.1.47",
107
+ "peaks-loop-shared": "0.0.83",
108
+ "peaks-loop-shared-channel": "0.0.51"
109
109
  },
110
110
  "devDependencies": {
111
111
  "@changesets/cli": "2.31.1",