peaks-loop 4.0.8 → 4.0.10
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 +38 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/baseline-commands.d.ts +3 -0
- package/dist/cli/commands/baseline-commands.js +146 -0
- package/dist/cli/commands/container-commands.js +2 -1
- package/dist/cli/commands/core/skill-command.js +32 -5
- package/dist/cli/commands/openspec-commands.js +2 -1
- package/dist/cli/commands/statusline-commands.js +1 -1
- package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +50 -39
- package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
- package/dist/services/capability-audit-service/cross-check.js +11 -0
- package/dist/services/capability-audit-service/index.d.ts +3 -0
- package/dist/services/capability-audit-service/index.js +2 -0
- package/dist/services/capability-audit-service/runner.d.ts +27 -0
- package/dist/services/capability-audit-service/runner.js +40 -0
- package/dist/services/capability-audit-service/staleness.d.ts +1 -0
- package/dist/services/capability-audit-service/staleness.js +7 -0
- package/dist/services/capability-audit-service/types.d.ts +24 -0
- package/dist/services/capability-audit-service/types.js +1 -0
- package/dist/services/capability-baseline/store.d.ts +33 -0
- package/dist/services/capability-baseline/store.js +80 -0
- package/dist/services/capability-baseline/types.d.ts +45 -0
- package/dist/services/capability-baseline/types.js +5 -0
- package/dist/services/capability-baseline/validator.d.ts +19 -0
- package/dist/services/capability-baseline/validator.js +38 -0
- package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
- package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
- package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
- package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
- package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
- package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
- package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
- package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
- package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
- package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
- package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
- package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
- package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
- package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
- package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
- package/dist/services/capability-guard-runner/diff.d.ts +2 -0
- package/dist/services/capability-guard-runner/diff.js +7 -0
- package/dist/services/capability-guard-runner/runner.d.ts +9 -0
- package/dist/services/capability-guard-runner/runner.js +22 -0
- package/dist/services/capability-guard-runner/types.d.ts +39 -0
- package/dist/services/capability-guard-runner/types.js +1 -0
- package/dist/services/container/container-lease.js +2 -1
- package/dist/services/evolution/evolution-types.d.ts +16 -16
- package/dist/services/final-review/final-review-service.d.ts +8 -0
- package/dist/services/final-review/final-review-service.js +14 -0
- package/dist/services/final-review/final-review-types.d.ts +1 -1
- package/dist/services/impact/impact-scan-service.js +4 -3
- package/dist/services/job/job-progress-store.d.ts +2 -2
- package/dist/services/job/job-types.d.ts +26 -26
- package/dist/services/migrate-skill-name/migrate.js +2 -1
- package/dist/services/observability/observability-service.d.ts +2 -2
- package/dist/services/openspec/artifact-boundary.js +3 -2
- package/dist/services/openspec/coverage-evidence-reader.js +9 -8
- package/dist/services/prd/handoff-auto-regen.js +2 -1
- package/dist/services/scan/type-sanity-service.js +2 -1
- package/dist/services/sediment/json-schema.d.ts +4 -4
- package/dist/services/session/session-binding-bridge.js +17 -19
- package/dist/services/session/session-manager.js +36 -12
- package/dist/services/session/session-resume-service.d.ts +4 -0
- package/dist/services/session/session-resume-service.js +4 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
- package/dist/services/skills/skill-statusline-renderer.js +335 -126
- package/dist/services/skills/skill-statusline-service.d.ts +6 -0
- package/dist/services/skills/skill-statusline-service.js +105 -7
- package/dist/services/vm/vm-lease.js +2 -1
- package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
- package/dist/services/workspace/workspace-service.js +2 -1
- package/dist/services/worktree/worktree-lease.js +2 -1
- package/dist/shared/path-safety.js +3 -5
- package/dist/shared/path-utils.d.ts +48 -0
- package/dist/shared/path-utils.js +65 -1
- package/package.json +5 -4
|
@@ -3,6 +3,8 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import { findProjectRoot } from '../config/config-safety.js';
|
|
4
4
|
import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
|
|
5
5
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
6
|
+
import { resolveActiveSkillForCaller } from '../audit/enforcers/active-skill-resolver.js';
|
|
7
|
+
import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
|
|
6
8
|
/**
|
|
7
9
|
* Out-of-band Peaks skill status renderer for the Claude Code statusLine.
|
|
8
10
|
*
|
|
@@ -47,11 +49,93 @@ export function parseStatusLineStdin(raw) {
|
|
|
47
49
|
return null;
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the callerId for the read-side isolation. Order of resolution:
|
|
54
|
+
* 1. `stdin?.caller_id` (when the harness / IDE adapter forwards it)
|
|
55
|
+
* 2. `process.env.CLAUDE_CODE_SESSION_ID` (Claude Code's ambient session id;
|
|
56
|
+
* used as a coarse callerId surrogate when stdin omits caller_id)
|
|
57
|
+
* 3. `null` (no callerId — caller falls back to the project-level
|
|
58
|
+
* single-file read for back-compat)
|
|
59
|
+
*/
|
|
60
|
+
function resolveCallerId(stdin) {
|
|
61
|
+
const fromStdin = typeof stdin?.caller_id === 'string' && stdin.caller_id.length > 0
|
|
62
|
+
? stdin.caller_id
|
|
63
|
+
: null;
|
|
64
|
+
if (fromStdin !== null)
|
|
65
|
+
return fromStdin;
|
|
66
|
+
const fromEnv = process.env['CLAUDE_CODE_SESSION_ID'];
|
|
67
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0)
|
|
68
|
+
return fromEnv;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read the active-dispatch index for the canonical session, filter to
|
|
73
|
+
* in-flight entries (status NOT IN { done, failed, cancelled, no-execution,
|
|
74
|
+
* never-started, unreadable, stale }), and return the most-recent leaf role
|
|
75
|
+
* plus the total in-flight count. Returns `{ role: null, pendingCount: 0 }`
|
|
76
|
+
* when no session id resolves, the index is empty, or every entry is terminal.
|
|
77
|
+
*
|
|
78
|
+
* READ-ONLY: only reads `.peaks/_sub_agents/<sid>/active-dispatches.json`.
|
|
79
|
+
* Never mutates the on-disk record.
|
|
80
|
+
*/
|
|
81
|
+
function readActiveLeaf(projectRoot, sessionId) {
|
|
82
|
+
if (sessionId === null)
|
|
83
|
+
return null;
|
|
84
|
+
let index = {};
|
|
85
|
+
try {
|
|
86
|
+
index = readActiveDispatchIndex(projectRoot, sessionId);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const terminalStatuses = new Set([
|
|
92
|
+
'done',
|
|
93
|
+
'failed',
|
|
94
|
+
'cancelled',
|
|
95
|
+
'no-execution',
|
|
96
|
+
'never-started',
|
|
97
|
+
'unreadable',
|
|
98
|
+
'stale',
|
|
99
|
+
]);
|
|
100
|
+
const inFlight = Object.values(index).filter((e) => !terminalStatuses.has(e.status));
|
|
101
|
+
if (inFlight.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
// Sort by createdAt descending — the most recently dispatched leaf wins.
|
|
104
|
+
const sorted = inFlight.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
105
|
+
const latest = sorted[0];
|
|
106
|
+
if (latest === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
return { role: latest.role, pendingCount: inFlight.length };
|
|
109
|
+
}
|
|
50
110
|
/**
|
|
51
111
|
* Read the presence file without any side effects. Returns null when the file is
|
|
52
112
|
* absent (idle) and a sentinel object for malformed content (invalid-presence).
|
|
113
|
+
*
|
|
114
|
+
* When `callerId` is non-null, delegates to the canonical lease resolver
|
|
115
|
+
* (presence-lease-service via active-skill-resolver) so the read is
|
|
116
|
+
* session+caller-isolated. When `callerId` is null, falls back to the
|
|
117
|
+
* project-level single-file read (back-compat for callers that don't pass
|
|
118
|
+
* a callerId yet, e.g. legacy CLI invocations).
|
|
53
119
|
*/
|
|
54
|
-
function readPresenceReadOnly(projectRoot) {
|
|
120
|
+
function readPresenceReadOnly(projectRoot, callerId) {
|
|
121
|
+
if (callerId !== null) {
|
|
122
|
+
try {
|
|
123
|
+
const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
|
|
124
|
+
if (resolution.source === 'none' || resolution.skill === null) {
|
|
125
|
+
return { presence: null, invalid: false };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
presence: {
|
|
129
|
+
skill: resolution.skill,
|
|
130
|
+
...(resolution.mode !== null ? { mode: resolution.mode } : {}),
|
|
131
|
+
},
|
|
132
|
+
invalid: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { presence: null, invalid: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
55
139
|
const presencePath = resolve(projectRoot, PRESENCE_FILE);
|
|
56
140
|
// Back-compat: prefer the new canonical path; fall back to the legacy
|
|
57
141
|
// `.peaks/.active-skill.json` for one minor release.
|
|
@@ -91,14 +175,16 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
91
175
|
// fallbacks). It replaces the active skill content when kind != 'none'.
|
|
92
176
|
const compact = readCompactState(projectRoot, nowMs);
|
|
93
177
|
if (projectRoot === null) {
|
|
94
|
-
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact };
|
|
178
|
+
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
95
179
|
}
|
|
96
|
-
|
|
180
|
+
// callerId resolves the read-side isolation; back-compat is `null`.
|
|
181
|
+
const callerId = resolveCallerId(stdin);
|
|
182
|
+
const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
|
|
97
183
|
if (invalid) {
|
|
98
|
-
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact };
|
|
184
|
+
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
99
185
|
}
|
|
100
186
|
if (presence === null) {
|
|
101
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
|
|
187
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
102
188
|
}
|
|
103
189
|
// Session binding: when the presence was stamped with a Claude session id and
|
|
104
190
|
// the live session (from stdin) is a different one, the recorded skill belongs
|
|
@@ -107,12 +193,24 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
107
193
|
// fall back to the time-based behavior below for backward compatibility.
|
|
108
194
|
const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
|
|
109
195
|
if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
|
|
110
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
|
|
196
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
111
197
|
}
|
|
112
198
|
const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
|
|
113
199
|
const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
|
|
114
200
|
const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
|
|
115
|
-
|
|
201
|
+
// Active leaf resolution: read-only query against the per-session
|
|
202
|
+
// active-dispatches index. Filter to in-flight entries; pick the most
|
|
203
|
+
// recent by createdAt. The renderer uses this to surface the in-flight
|
|
204
|
+
// bee skill (e.g. `peaks-rd`) alongside the orchestrator (e.g. `peaks-code`).
|
|
205
|
+
let activeLeaf = null;
|
|
206
|
+
try {
|
|
207
|
+
const sessionId = getSessionIdCanonical(projectRoot);
|
|
208
|
+
activeLeaf = readActiveLeaf(projectRoot, sessionId);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
activeLeaf = null;
|
|
212
|
+
}
|
|
213
|
+
return { state, projectRoot, presence, ageMs, compact, activeLeaf };
|
|
116
214
|
}
|
|
117
215
|
/**
|
|
118
216
|
* Read-only compact state resolver. Resolves the canonical session id for the
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { posix as path } from 'node:path';
|
|
23
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
23
24
|
export const VM_HYPERVISORS = Object.freeze([
|
|
24
25
|
'kvm', 'hyperkit', 'hyperv'
|
|
25
26
|
]);
|
|
@@ -128,7 +129,7 @@ export function deserializeVmLease(raw) {
|
|
|
128
129
|
function joinPath(...segments) {
|
|
129
130
|
if (segments.length === 0)
|
|
130
131
|
return '';
|
|
131
|
-
const normalized = segments.map((s) => s
|
|
132
|
+
const normalized = segments.map((s) => normalizePath(s));
|
|
132
133
|
let acc = normalized[0];
|
|
133
134
|
for (let i = 1; i < normalized.length; i++) {
|
|
134
135
|
acc = path.join(acc, normalized[i]);
|
|
@@ -20,6 +20,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
|
|
|
20
20
|
// `.peaks/_runtime/<sessionId>/<role>/...` string is no longer
|
|
21
21
|
// pre-built.
|
|
22
22
|
import { getSessionDir } from '../session/getSessionDir.js';
|
|
23
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
23
24
|
const MAX_RESUME_ARTIFACT_BYTES = 256_000;
|
|
24
25
|
// Slice 2026-06-29-change-id-root-removal: return the bare role-relative
|
|
25
26
|
// sub-paths instead of `.peaks/_runtime/change/<id>/<role>/...` strings.
|
|
@@ -65,7 +66,7 @@ function normalizeRoleRelativePath(artifact, _sessionId) {
|
|
|
65
66
|
// the path separators and strips any leading `/`; the `sessionId`
|
|
66
67
|
// argument is preserved on the signature for backward call-site
|
|
67
68
|
// compatibility but is no longer embedded in the path.
|
|
68
|
-
return artifact
|
|
69
|
+
return normalizePath(artifact).replace(/^\/+/, '');
|
|
69
70
|
}
|
|
70
71
|
function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
|
|
71
72
|
// Slice 2026-06-29-change-id-root-removal: on-disk home now lives under
|
|
@@ -73,7 +74,7 @@ function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
|
|
|
73
74
|
// sub-path (e.g. `rd/swarm/checkpoints/checkpoint-1.json`) is
|
|
74
75
|
// strictly a sub-root drill, not a top-level dir derivation.
|
|
75
76
|
const sessionScopeRoot = getSessionDir(artifactWorkspacePath, sessionId);
|
|
76
|
-
const normalizedArtifact = artifact
|
|
77
|
+
const normalizedArtifact = normalizePath(artifact).replace(/^\/+/, '');
|
|
77
78
|
const artifactPath = resolve(sessionScopeRoot, normalizedArtifact);
|
|
78
79
|
try {
|
|
79
80
|
const artifactWorkspaceRealPath = realpathSync(artifactWorkspacePath);
|
|
@@ -3,6 +3,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isDirectory } from 'peaks-loop-shared/fs';
|
|
5
5
|
import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
|
|
6
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
6
7
|
/**
|
|
7
8
|
* Slice 2026-06-29-change-id-root-removal: list the immediate children of
|
|
8
9
|
* `.peaks/` so the legacy sibling-dir guard can enumerate date-stamped
|
|
@@ -418,7 +419,7 @@ export function isWriterCreatedSiblingShape(siblingDir) {
|
|
|
418
419
|
continue;
|
|
419
420
|
}
|
|
420
421
|
if (stat.isFile()) {
|
|
421
|
-
const rel = node.rel
|
|
422
|
+
const rel = normalizePath(node.rel);
|
|
422
423
|
const matches = WRITER_ALLOWED_RELATIVE_PATTERNS.some((re) => re.test(rel));
|
|
423
424
|
if (!matches) {
|
|
424
425
|
return false;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { randomBytes } from 'node:crypto';
|
|
28
28
|
import { posix as path } from 'node:path';
|
|
29
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
29
30
|
/**
|
|
30
31
|
* Per-role default TTL. Sub-agent dispatch duration varies by role:
|
|
31
32
|
* - rd (long planning + impl) → 30 min
|
|
@@ -233,7 +234,7 @@ export function deserializeLease(raw) {
|
|
|
233
234
|
function joinPath(...segments) {
|
|
234
235
|
if (segments.length === 0)
|
|
235
236
|
return '';
|
|
236
|
-
const normalized = segments.map((s) => s
|
|
237
|
+
const normalized = segments.map((s) => normalizePath(s));
|
|
237
238
|
let acc = normalized[0];
|
|
238
239
|
for (let i = 1; i < normalized.length; i++) {
|
|
239
240
|
acc = path.join(acc, normalized[i]);
|
|
@@ -10,11 +10,9 @@
|
|
|
10
10
|
* check without bringing in the change-id axis.
|
|
11
11
|
*/
|
|
12
12
|
import { posix } from 'node:path';
|
|
13
|
-
|
|
14
|
-
return input.replace(/\\/g, '/');
|
|
15
|
-
}
|
|
13
|
+
import { normalizePath } from './path-utils.js';
|
|
16
14
|
function hasUnsafePathShape(input) {
|
|
17
|
-
const normalized =
|
|
15
|
+
const normalized = normalizePath(input);
|
|
18
16
|
if (input.includes('\\'))
|
|
19
17
|
return true;
|
|
20
18
|
if (!normalized || normalized === '.' || normalized === '..')
|
|
@@ -36,7 +34,7 @@ export function isUnsafeArtifactPath(path) {
|
|
|
36
34
|
return isUnsafePathInput(path);
|
|
37
35
|
}
|
|
38
36
|
function normalizeArtifactPath(input) {
|
|
39
|
-
const normalized = posix.normalize(
|
|
37
|
+
const normalized = posix.normalize(normalizePath(input));
|
|
40
38
|
return normalized.replace(/\/$/, '');
|
|
41
39
|
}
|
|
42
40
|
export { normalizeArtifactPath as _normalizeArtifactPath };
|
|
@@ -11,3 +11,51 @@ export declare function isWindowsAbsolutePath(path: string): boolean;
|
|
|
11
11
|
export declare function resolveInputPath(path: string): string;
|
|
12
12
|
export declare function stableRealPath(path: string): string;
|
|
13
13
|
export declare function stablePath(path: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Build the comparison key for a `projectRoot` value. Two paths that
|
|
16
|
+
* denote the same physical directory MUST produce the same key, and two
|
|
17
|
+
* paths that denote different directories MUST NOT.
|
|
18
|
+
*
|
|
19
|
+
* Composed from the existing `path-utils.ts` primitives; no new
|
|
20
|
+
* dependency. Three normalizations, in order:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Symlink resolution** via `stableRealPath` — collapses macOS
|
|
23
|
+
* `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
|
|
24
|
+
* symlink to `/private/var`). Falls back to `resolveInputPath`
|
|
25
|
+
* when the path does not exist, so this never throws for callers
|
|
26
|
+
* that pass a not-yet-created root (or a stale binding whose
|
|
27
|
+
* directory has since been deleted).
|
|
28
|
+
* 2. **Separator folding** via `normalizePath` — collapses the
|
|
29
|
+
* Windows `C:\Users\...` (written by `peaks workspace init`) vs
|
|
30
|
+
* `C:/Users/...` (passed by a Git Bash caller) split that was the
|
|
31
|
+
* original `PEAKS_SESSION_NOT_BOUND` trigger.
|
|
32
|
+
* 3. **Case folding, Windows only** — NTFS is case-insensitive, so
|
|
33
|
+
* `C:\Foo` and `c:/foo` are the same directory. This step is
|
|
34
|
+
* guarded on `isWindows` because POSIX filesystems are
|
|
35
|
+
* case-SENSITIVE: folding case on Linux would make the genuinely
|
|
36
|
+
* distinct `/tmp/Foo` and `/tmp/foo` compare equal.
|
|
37
|
+
*
|
|
38
|
+
* Note on step 3: `realpathSync` alone is NOT sufficient for the
|
|
39
|
+
* Windows case split. Verified empirically on Node 22 / win32 —
|
|
40
|
+
* `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
|
|
41
|
+
* `realpathSync.native` case-folds. We do not switch to `.native`
|
|
42
|
+
* because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
|
|
43
|
+
* which would change the on-disk form of every existing binding.
|
|
44
|
+
* Explicit `toLowerCase()` is the narrower, more predictable fix.
|
|
45
|
+
*
|
|
46
|
+
* This is a compare key ONLY — never persist it. The value written to
|
|
47
|
+
* disk stays the real path (see `writeSessionFile`).
|
|
48
|
+
*
|
|
49
|
+
* Lifted from `src/services/session/session-manager.ts` in slice
|
|
50
|
+
* `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
|
|
51
|
+
* and `session-binding-bridge.ts` share a single canonicalization
|
|
52
|
+
* authority.
|
|
53
|
+
*/
|
|
54
|
+
export declare function projectRootCompareKey(p: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Compare two `projectRoot` values under the canonicalization rules
|
|
57
|
+
* documented on `projectRootCompareKey`. Returns true iff both paths
|
|
58
|
+
* canonicalize to the same key — covers symlink resolution, separator
|
|
59
|
+
* folding, and (Windows only) case folding.
|
|
60
|
+
*/
|
|
61
|
+
export declare function projectRootsMatch(a: string, b: string): boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, parse, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { platform } from './platform.js';
|
|
4
|
+
import { isWindows, platform } from './platform.js';
|
|
5
5
|
export const SEP = sep;
|
|
6
6
|
const localPathConverters = {
|
|
7
7
|
win32: (p) => p.replace(/\//g, '\\'),
|
|
@@ -54,3 +54,67 @@ export function stablePath(path) {
|
|
|
54
54
|
const realExistingPath = existsSync(currentPath) ? stableRealPath(currentPath) : parsedPath.root;
|
|
55
55
|
return resolve(realExistingPath, ...missingSegments);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the comparison key for a `projectRoot` value. Two paths that
|
|
59
|
+
* denote the same physical directory MUST produce the same key, and two
|
|
60
|
+
* paths that denote different directories MUST NOT.
|
|
61
|
+
*
|
|
62
|
+
* Composed from the existing `path-utils.ts` primitives; no new
|
|
63
|
+
* dependency. Three normalizations, in order:
|
|
64
|
+
*
|
|
65
|
+
* 1. **Symlink resolution** via `stableRealPath` — collapses macOS
|
|
66
|
+
* `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
|
|
67
|
+
* symlink to `/private/var`). Falls back to `resolveInputPath`
|
|
68
|
+
* when the path does not exist, so this never throws for callers
|
|
69
|
+
* that pass a not-yet-created root (or a stale binding whose
|
|
70
|
+
* directory has since been deleted).
|
|
71
|
+
* 2. **Separator folding** via `normalizePath` — collapses the
|
|
72
|
+
* Windows `C:\Users\...` (written by `peaks workspace init`) vs
|
|
73
|
+
* `C:/Users/...` (passed by a Git Bash caller) split that was the
|
|
74
|
+
* original `PEAKS_SESSION_NOT_BOUND` trigger.
|
|
75
|
+
* 3. **Case folding, Windows only** — NTFS is case-insensitive, so
|
|
76
|
+
* `C:\Foo` and `c:/foo` are the same directory. This step is
|
|
77
|
+
* guarded on `isWindows` because POSIX filesystems are
|
|
78
|
+
* case-SENSITIVE: folding case on Linux would make the genuinely
|
|
79
|
+
* distinct `/tmp/Foo` and `/tmp/foo` compare equal.
|
|
80
|
+
*
|
|
81
|
+
* Note on step 3: `realpathSync` alone is NOT sufficient for the
|
|
82
|
+
* Windows case split. Verified empirically on Node 22 / win32 —
|
|
83
|
+
* `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
|
|
84
|
+
* `realpathSync.native` case-folds. We do not switch to `.native`
|
|
85
|
+
* because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
|
|
86
|
+
* which would change the on-disk form of every existing binding.
|
|
87
|
+
* Explicit `toLowerCase()` is the narrower, more predictable fix.
|
|
88
|
+
*
|
|
89
|
+
* This is a compare key ONLY — never persist it. The value written to
|
|
90
|
+
* disk stays the real path (see `writeSessionFile`).
|
|
91
|
+
*
|
|
92
|
+
* Lifted from `src/services/session/session-manager.ts` in slice
|
|
93
|
+
* `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
|
|
94
|
+
* and `session-binding-bridge.ts` share a single canonicalization
|
|
95
|
+
* authority.
|
|
96
|
+
*/
|
|
97
|
+
export function projectRootCompareKey(p) {
|
|
98
|
+
let canonical;
|
|
99
|
+
try {
|
|
100
|
+
canonical = stableRealPath(p);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Path does not exist (yet). Fall back to the resolved absolute
|
|
104
|
+
// form so a binding written before its directory exists — and a
|
|
105
|
+
// binding whose directory was later deleted — still compares by
|
|
106
|
+
// separator + case rather than throwing.
|
|
107
|
+
canonical = resolveInputPath(p);
|
|
108
|
+
}
|
|
109
|
+
const separatorFolded = normalizePath(canonical);
|
|
110
|
+
return isWindows ? separatorFolded.toLowerCase() : separatorFolded;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Compare two `projectRoot` values under the canonicalization rules
|
|
114
|
+
* documented on `projectRootCompareKey`. Returns true iff both paths
|
|
115
|
+
* canonicalize to the same key — covers symlink resolution, separator
|
|
116
|
+
* folding, and (Windows only) case folding.
|
|
117
|
+
*/
|
|
118
|
+
export function projectRootsMatch(a, b) {
|
|
119
|
+
return projectRootCompareKey(a) === projectRootCompareKey(b);
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.10",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"keywords": [
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"headroom-ai": "0.22.4",
|
|
99
99
|
"yaml": "^2.9.0",
|
|
100
100
|
"zod": "^3.25.76",
|
|
101
|
-
"peaks-loop-
|
|
102
|
-
"peaks-loop-shared-channel": "0.0.
|
|
103
|
-
"peaks-loop-
|
|
101
|
+
"peaks-loop-shared": "0.0.41",
|
|
102
|
+
"peaks-loop-shared-channel": "0.0.19",
|
|
103
|
+
"peaks-loop-mut": "0.1.15"
|
|
104
104
|
},
|
|
105
105
|
"peerDependencies": {
|
|
106
106
|
"@alibaba-group/open-code-review": "1.3.1"
|
|
@@ -142,6 +142,7 @@
|
|
|
142
142
|
"test:dev:cli": "vitest run tests/unit",
|
|
143
143
|
"test:unit": "vitest run tests/unit",
|
|
144
144
|
"test:integration": "vitest run tests/integration",
|
|
145
|
+
"test:capability-guard": "vitest run --config vitest.config.integration.ts tests/integration/capability-guard",
|
|
145
146
|
"test:cli": "vitest run tests/unit",
|
|
146
147
|
"test:workflow": "vitest run tests/unit",
|
|
147
148
|
"test:audit:silent-warning": "node ./scripts/lint/silent-warning-detector.mjs",
|