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.
- package/CHANGELOG.md +20 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/compact-command.js +1 -3
- 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/loop-eval-commands.js +22 -6
- package/dist/cli/commands/slice-integrate-commands.js +17 -0
- package/dist/services/artifacts/artifact-prerequisites.js +10 -0
- package/dist/services/artifacts/request-artifact-service.js +59 -38
- package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
- package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
- package/dist/services/code/auto-compact-lifecycle.js +65 -16
- package/dist/services/code/auto-compact-orchestrator.js +119 -19
- package/dist/services/code/compact-event-settle.d.ts +20 -8
- package/dist/services/code/compact-event-settle.js +21 -0
- package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
- package/dist/services/context/auto-compact-types.d.ts +20 -2
- 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/job/job-progress-store.js +18 -3
- 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/prd/handoff-service.js +43 -0
- package/dist/services/qa/qa-business-review-state.js +19 -5
- package/dist/services/sc/sc-service.d.ts +8 -0
- package/dist/services/sc/sc-service.js +8 -1
- package/dist/services/session/getSessionDir.d.ts +33 -0
- package/dist/services/session/getSessionDir.js +60 -0
- package/dist/services/slice/slice-review-state.js +19 -4
- package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
- package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
- package/dist/services/workflow/pipeline-verify-service.js +23 -10
- package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
- package/dist/shared/runtime-root.d.ts +73 -0
- package/dist/shared/runtime-root.js +77 -0
- 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
|
-
|
|
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
|
|
37
|
-
// the runtime tree through this one constructor. Measured:
|
|
38
|
-
// ../../../../…/PWNED` wrote `slice-reviews/<slice-id>.json`
|
|
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)
|
|
32
|
-
// `showRequestArtifact`
|
|
33
|
-
// as `sessionId
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
182
|
-
// sidecar)
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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)
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
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.
|
|
106
|
-
"peaks-loop-
|
|
107
|
-
"peaks-loop-shared": "0.0.
|
|
108
|
-
"peaks-loop-
|
|
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",
|