peaks-loop 4.0.9 → 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 +26 -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/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +50 -39
- package/dist/services/container/container-lease.js +2 -1
- package/dist/services/impact/impact-scan-service.js +4 -3
- package/dist/services/migrate-skill-name/migrate.js +2 -1
- 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/session/session-binding-bridge.js +17 -19
- package/dist/services/session/session-manager.js +36 -12
- package/dist/services/skills/skill-statusline-renderer.js +29 -32
- 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 +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.10 — 2026-08-04 (path-canonicalize + statusline-read-isolation)
|
|
4
|
+
|
|
5
|
+
**Windows statusline fixed.** `peaks-loop@4.0.9` always rendered `peaks empty` on Windows Git Bash. Root cause: the session-binding reader used strict `===` to compare `projectRoot`; the binding had been written with backslashes (`C:\Users\...`) but `peaks skill presence:set --project C:/Users/...` arrived with forward slashes, and Node treats them as distinct strings. The 4.0.8 fail-closed `PEAKS_SESSION_NOT_BOUND` gate then blocked the presence marker write, and the statusline never had a real skill to display.
|
|
6
|
+
|
|
7
|
+
**Cross-platform path canonicalization** (5 rid: `5ae2fa6d` / `e8b467d8` / `4e22ce39` / `033df6f6` / `8da617de`):
|
|
8
|
+
- `src/services/session/session-manager.ts` + `src/services/session/session-binding-bridge.ts` — `readSessionFile` / `writeSessionFile` use the canonical `projectRootsMatch` (stableRealPath + normalizePath + isWindows case-fold).
|
|
9
|
+
- `src/shared/path-utils.ts` — `projectRootsMatch` lifted from session-manager to be the single source of truth for cross-platform path comparison.
|
|
10
|
+
- 17 out-of-scope `replace(/\\/g, '/')` sites consolidated to `path-utils.normalizePath`. The remaining 1 site (`karpathy-service.ts:222`) is a reverse escape (`\\` → `\\\\`) and is intentionally out of scope.
|
|
11
|
+
- Hard rule entered `.peaks/standards/common/coding-style.md`: no strict `===` on filesystem paths, no hand-rolled `realpathSync` in non-`path-utils` modules, no `path.replace(/\\/g, '/')` in new code. Sediment at `.peaks/memory/2026-08-04-cross-platform-path-utility-rule.md`.
|
|
12
|
+
|
|
13
|
+
**Statusline read-side isolation** (1 rid: `8da617de`):
|
|
14
|
+
- `peaks statusline` now reads from `active-skill-resolver.resolveActiveSkillForCaller` (canonical lease walk) instead of the project-level single file. callerId extracted from `stdin.caller_id` → `CLAUDE_CODE_SESSION_ID` env → null fallback.
|
|
15
|
+
- Multi-session isolation: `peaks skill presence:set` in session A no longer leaks into session B's statusline.
|
|
16
|
+
- Active leaf display: statusline now shows `${leaf} (+N-1) | peaks-code [mode]` when sub-agents are in flight (e.g. `peaks-rd | peaks-code [full-auto]`). The 14→1 bee skill → `peaks-code` mapping in the renderer is removed.
|
|
17
|
+
- **Bug fix discovered and closed during rid-005**: `active-skill-resolver.ts` was reading from the session directory root expecting `presence-*.json` files, but `presence-lease-service.ts` actually writes under `leases/` subdir. Option A fix: resolver now reads from the canonical `leases/` subdir via `listPresenceLeases`.
|
|
18
|
+
|
|
19
|
+
**Shorter animation periods** (2 rid: `52a968f7` / `532bd1b1`):
|
|
20
|
+
- `MARQUEE_PERIOD_MS` 2000 → 800 → 400 (statusline scan band round trip)
|
|
21
|
+
- `BREATHING_PERIOD_MS` 2400 → 1200 → 600 (breathing glyph rotation)
|
|
22
|
+
- `MARQUEE_BAND_WIDTH` 5 → 3 → 2 (highlight band width)
|
|
23
|
+
- At 400ms / 600ms periods, 0.5-1s turn intervals produce visibly different breathing glyph + band position on every statusline render.
|
|
24
|
+
|
|
25
|
+
**Tests.** 88/88 statusline vitest green; 47 path-canonicalize test cases green across 4 test files. ESM repros validate 0.5-1.5s turn-interval phase difference at the production level.
|
|
26
|
+
|
|
27
|
+
**Lockstep bump.** peaks-loop-shared `0.0.39 → 0.0.40` (CLI_VERSION re-stamped to 4.0.10).
|
|
28
|
+
|
|
3
29
|
## 4.0.9 — 2026-08-04 (statusline-marquee)
|
|
4
30
|
|
|
5
31
|
**statusline: brand purple + scan-band marquee + NO_COLOR support.**
|
|
@@ -30,6 +30,7 @@ import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
|
30
30
|
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
31
31
|
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
32
32
|
import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
|
|
33
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
33
34
|
import { containerLeaseFilePath, deserializeContainerLease, finalizeContainerLease, generateContainerLeaseId, markContainerReleased, ttlForContainerRole } from '../../services/container/container-lease.js';
|
|
34
35
|
const DEFAULT_DOCKER_IMAGE = 'node:22-slim';
|
|
35
36
|
function detectContainerRuntime(explicit) {
|
|
@@ -114,7 +115,7 @@ export function registerContainerCommand(program, io) {
|
|
|
114
115
|
// `--label peaks.leaseId=<id>` lets `peaks container
|
|
115
116
|
// list` / `peaks container gc` find orphans by label
|
|
116
117
|
// when the lease file is missing.
|
|
117
|
-
const cidFile = `${joinPathSession(projectRoot, sessionId)
|
|
118
|
+
const cidFile = `${normalizePath(joinPathSession(projectRoot, sessionId))}/.${runtime.runtime}-cid-${leaseId}`;
|
|
118
119
|
try {
|
|
119
120
|
execSync(`${runtime.runtime} run --rm -d --cidfile "${cidFile}" --label "peaks.leaseId=${leaseId}" --label "peaks.rid=${options.rid}" -v "${mount}:/work" -w /work ${image} sleep infinity`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
|
|
120
121
|
}
|
|
@@ -10,6 +10,31 @@ import { getSessionId, setSessionMeta } from '../../../services/session/session-
|
|
|
10
10
|
import { resolveCallerProjection } from '../../../services/session/resolve-caller-id.js';
|
|
11
11
|
import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
|
|
12
12
|
import { fail, ok } from 'peaks-loop-shared/result';
|
|
13
|
+
import { stableRealPath } from '../../../shared/path-utils.js';
|
|
14
|
+
/**
|
|
15
|
+
* Canonicalize a user-supplied `--project <path>` value.
|
|
16
|
+
*
|
|
17
|
+
* Git Bash on Windows hands us forward-slash paths
|
|
18
|
+
* (`C:/Users/.../peaks-loop`) while `peaks workspace init` writes the
|
|
19
|
+
* backslash form, and either side may carry a trailing separator or
|
|
20
|
+
* differing case. Resolving to the real path here means every
|
|
21
|
+
* downstream consumer (`getSessionId`, `setSessionMeta`,
|
|
22
|
+
* `setSkillPresence`) sees one stable form.
|
|
23
|
+
*
|
|
24
|
+
* Returns the input unchanged when it cannot be resolved (path does
|
|
25
|
+
* not exist yet, or is not readable) so a bad `--project` still
|
|
26
|
+
* reaches the existing error handling rather than throwing here.
|
|
27
|
+
*/
|
|
28
|
+
function canonicalizeProjectOption(project) {
|
|
29
|
+
if (project === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return stableRealPath(project);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return project;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
|
|
14
39
|
// Slice S0 (4.0.0-beta.5 peaks-solo dispatcher release):
|
|
15
40
|
// `peaks skill search` is the CLI primitive that feeds the
|
|
@@ -143,7 +168,8 @@ export function registerSkillCommand(program, io) {
|
|
|
143
168
|
.description('Show the currently active Peaks skill (alias: presence:get)')
|
|
144
169
|
.option('--check-stale', 'slice 002 (v2.15.0): also report whether the recorded outer session id still matches the current one. Default false (back-compat).')
|
|
145
170
|
.option('--project <path>', 'project root (default: cwd)')).action((options) => {
|
|
146
|
-
const
|
|
171
|
+
const projectOption = canonicalizeProjectOption(options.project);
|
|
172
|
+
const presence = getSkillPresence(projectOption);
|
|
147
173
|
if (presence === null) {
|
|
148
174
|
printResult(io, ok('skill.presence', { active: false }), options.json);
|
|
149
175
|
return;
|
|
@@ -154,7 +180,7 @@ export function registerSkillCommand(program, io) {
|
|
|
154
180
|
// pieces of info from a single CLI invocation. The presence
|
|
155
181
|
// is returned UNCHANGED — `--check-stale` is a read-only flag,
|
|
156
182
|
// not a clear.
|
|
157
|
-
const staleness = checkStalePresence({ projectRootOverride:
|
|
183
|
+
const staleness = checkStalePresence({ projectRootOverride: projectOption });
|
|
158
184
|
printResult(io, ok('skill.presence', {
|
|
159
185
|
active: true,
|
|
160
186
|
...presence,
|
|
@@ -173,7 +199,8 @@ export function registerSkillCommand(program, io) {
|
|
|
173
199
|
.option('--mode <mode>', 'execution mode')
|
|
174
200
|
.option('--gate <gate>', 'current gate')
|
|
175
201
|
.option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
|
|
176
|
-
const
|
|
202
|
+
const projectOption = canonicalizeProjectOption(options.project);
|
|
203
|
+
const projectRoot = projectOption ?? findProjectRoot(process.cwd()) ?? process.cwd();
|
|
177
204
|
if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
|
|
178
205
|
printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
|
|
179
206
|
process.exitCode = 1;
|
|
@@ -198,11 +225,11 @@ export function registerSkillCommand(program, io) {
|
|
|
198
225
|
catch (err) {
|
|
199
226
|
const message = err instanceof Error ? err.message : String(err);
|
|
200
227
|
printResult(io, fail('skill.presence:set', 'PEAKS_CALLER_NOT_RESOLVED', `Active IDE adapter could not resolve a callerId (RD §3 D1): ${message}`, { projectRoot, name }, ['Ensure the active IDE is detected by `peaks` and the IDE session variable is set.',
|
|
201
|
-
'Or
|
|
228
|
+
'Or set PEAKS_CALLER_ID=<id> in the environment for scripted usage.']), options.json);
|
|
202
229
|
process.exitCode = 1;
|
|
203
230
|
return;
|
|
204
231
|
}
|
|
205
|
-
const presence = setSkillPresence(name, options.mode, options.gate,
|
|
232
|
+
const presence = setSkillPresence(name, options.mode, options.gate, projectOption);
|
|
206
233
|
// Session metadata is updated when a session is bound (read-only
|
|
207
234
|
// path: `getSessionId`). We do not auto-spawn a session.
|
|
208
235
|
if (boundSessionId !== null) {
|
|
@@ -9,11 +9,12 @@ import { proposeFromDoctor } from '../../services/openspec/openspec-propose-from
|
|
|
9
9
|
import { runDoctor } from '../../services/doctor/index.js';
|
|
10
10
|
import { fail, ok } from 'peaks-loop-shared/result';
|
|
11
11
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
12
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
12
13
|
function resolveScanOptions(project) {
|
|
13
14
|
if (project === undefined) {
|
|
14
15
|
return {};
|
|
15
16
|
}
|
|
16
|
-
return { openspecRoot: `${project
|
|
17
|
+
return { openspecRoot: `${normalizePath(project).replace(/\/$/, '')}/openspec` };
|
|
17
18
|
}
|
|
18
19
|
async function loadRenderRequest(requestPath) {
|
|
19
20
|
const raw = await readFile(requestPath, 'utf8');
|
|
@@ -28,6 +28,16 @@ export interface ActiveSkillResolution {
|
|
|
28
28
|
readonly skill: string | null;
|
|
29
29
|
readonly callerId: string | null;
|
|
30
30
|
readonly sessionId: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Mode token recorded on the canonical lease (e.g. `full-auto`,
|
|
33
|
+
* `assisted`, `swarm`, `strict`). `null` when the source is not
|
|
34
|
+
* `canonical` (the legacy `active-skill-*.json` files do not
|
|
35
|
+
* surface a mode field, and the `env` / `none` cases are test
|
|
36
|
+
* overrides). Slice 2026-08-04-rid-005 surfaced this so the
|
|
37
|
+
* statusline can render the orchestrator's mode alongside the
|
|
38
|
+
* active leaf role.
|
|
39
|
+
*/
|
|
40
|
+
readonly mode: string | null;
|
|
31
41
|
/** `canonical` = presence-lease-service; `legacy` = per-caller active-skill file; `env` = test override; `none` = nothing wired. */
|
|
32
42
|
readonly source: 'env' | 'file' | 'canonical' | 'none';
|
|
33
43
|
}
|
|
@@ -42,4 +52,5 @@ export interface ActiveSkillResolution {
|
|
|
42
52
|
*/
|
|
43
53
|
export declare function resolveActiveSkillForCaller(projectRoot: string, opts?: {
|
|
44
54
|
legacyPresence?: boolean;
|
|
55
|
+
callerId?: string | null;
|
|
45
56
|
}): ActiveSkillResolution;
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
* `legacyPresence === true` is passed by the caller
|
|
25
25
|
* 4. null (caller did not set a skill; enforcers can decide to skip)
|
|
26
26
|
*/
|
|
27
|
-
import { existsSync,
|
|
27
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
import { getSessionIdCanonical } from '../../session/session-manager.js';
|
|
30
30
|
import { getSessionDir } from '../../session/getSessionDir.js';
|
|
31
|
-
import { readPresenceLease } from '../../skills/presence-lease-service.js';
|
|
31
|
+
import { readPresenceLease, listPresenceLeases } from '../../skills/presence-lease-service.js';
|
|
32
32
|
import { getCurrentSessionId } from '../../skills/skill-presence-service.js';
|
|
33
33
|
const ACTIVE_SKILL_PREFIX = 'active-skill-';
|
|
34
34
|
/**
|
|
@@ -43,71 +43,82 @@ const ACTIVE_SKILL_PREFIX = 'active-skill-';
|
|
|
43
43
|
export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
44
44
|
const envOverride = process.env.PEAKS_ACTIVE_SKILL;
|
|
45
45
|
if (typeof envOverride === 'string' && envOverride.length > 0) {
|
|
46
|
-
return { skill: envOverride, callerId: null, sessionId: null, source: 'env' };
|
|
46
|
+
return { skill: envOverride, callerId: null, sessionId: null, mode: null, source: 'env' };
|
|
47
47
|
}
|
|
48
48
|
let sessionId = null;
|
|
49
49
|
try {
|
|
50
50
|
sessionId = getSessionIdCanonical(projectRoot);
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
|
-
return { skill: null, callerId: null, sessionId: null, source: 'none' };
|
|
53
|
+
return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
|
|
54
54
|
}
|
|
55
55
|
if (sessionId === null) {
|
|
56
|
-
return { skill: null, callerId: null, sessionId: null, source: 'none' };
|
|
56
|
+
return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
|
|
57
57
|
}
|
|
58
58
|
// 4.0.8 preferred: read the canonical lease projection. The lease
|
|
59
|
-
// service
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
59
|
+
// service writes leases to `<sessionDir>/leases/presence-<caller>-<workflow>.json`
|
|
60
|
+
// (RD §4). We enumerate that directory via `listPresenceLeases`,
|
|
61
|
+
// which is the lease-service's own helper and knows the canonical
|
|
62
|
+
// layout. The first in-flight (status === 'preparing' | 'running')
|
|
63
|
+
// lease wins; this preserves the prior behaviour of returning a
|
|
64
|
+
// single skill per resolution. When the lease dir is empty (e.g.
|
|
65
|
+
// ad-hoc / pre-migration projects) we fall through to the legacy
|
|
66
|
+
// walk below.
|
|
66
67
|
const sessionDir = getSessionDir(projectRoot, sessionId);
|
|
67
68
|
if (!existsSync(sessionDir)) {
|
|
68
|
-
return { skill: null, callerId: null, sessionId, source: 'none' };
|
|
69
|
+
return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
|
|
69
70
|
}
|
|
70
|
-
let
|
|
71
|
+
let canonicalLeases = [];
|
|
71
72
|
try {
|
|
72
|
-
|
|
73
|
+
canonicalLeases = listPresenceLeases(projectRoot, sessionId);
|
|
73
74
|
}
|
|
74
75
|
catch {
|
|
75
|
-
|
|
76
|
+
canonicalLeases = [];
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// The canonical presence-index lives at `<sessionDir>/presence-index/<callerId>.json`.
|
|
80
|
-
// We synthesise the read from any presence-* lease file we find;
|
|
81
|
-
// the readPresenceLease call surfaces a typed `PresenceProjection`
|
|
82
|
-
// and we map `lease.skill` → resolution.
|
|
83
|
-
if (!entry.startsWith('presence-'))
|
|
78
|
+
for (const lease of canonicalLeases) {
|
|
79
|
+
if (lease.status !== 'preparing' && lease.status !== 'running')
|
|
84
80
|
continue;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
if (typeof lease.skill !== 'string' || lease.skill.length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
// When a callerId is supplied, restrict the walk to that
|
|
84
|
+
// caller's lease. Slice 2026-08-04-rid-005 surfaces this for the
|
|
85
|
+
// statusline read so two concurrent sessions bound to the same
|
|
86
|
+
// project session do not see each other's skill.
|
|
87
|
+
if (typeof opts?.callerId === 'string' && opts.callerId.length > 0 && lease.callerId !== opts.callerId)
|
|
90
88
|
continue;
|
|
91
|
-
const workflowId = callerId.slice(lastDash + 1);
|
|
92
|
-
const pureCaller = callerId.slice(0, lastDash);
|
|
93
89
|
try {
|
|
94
90
|
const projection = readPresenceLease({
|
|
95
91
|
projectRoot,
|
|
96
92
|
sessionId,
|
|
97
|
-
callerId:
|
|
98
|
-
workflowId,
|
|
99
|
-
graphRef:
|
|
93
|
+
callerId: lease.callerId,
|
|
94
|
+
workflowId: lease.workflowId,
|
|
95
|
+
graphRef: lease.graphRef,
|
|
100
96
|
});
|
|
101
97
|
if (projection.lease !== null && typeof projection.lease.skill === 'string' && projection.lease.skill.length > 0) {
|
|
102
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
skill: projection.lease.skill,
|
|
100
|
+
callerId: lease.callerId,
|
|
101
|
+
sessionId,
|
|
102
|
+
mode: typeof projection.mode === 'string' && projection.mode.length > 0 ? projection.mode : null,
|
|
103
|
+
source: 'canonical',
|
|
104
|
+
};
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
|
-
catch { /* fall through to
|
|
107
|
+
catch { /* fall through to next lease */ }
|
|
106
108
|
}
|
|
107
109
|
// Legacy fall-back (one minor release). Gated on `legacyPresence:
|
|
108
|
-
// true` so production hooks prefer the canonical projection.
|
|
110
|
+
// true` so production hooks prefer the canonical projection. Reads
|
|
111
|
+
// the session dir root for the per-caller `active-skill-*.json`
|
|
112
|
+
// marker files written by the 4.0.7 `setSkillPresenceForCaller`.
|
|
109
113
|
if (opts?.legacyPresence === true) {
|
|
110
|
-
|
|
114
|
+
let legacyEntries = [];
|
|
115
|
+
try {
|
|
116
|
+
legacyEntries = readdirSync(sessionDir);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
legacyEntries = [];
|
|
120
|
+
}
|
|
121
|
+
for (const entry of legacyEntries) {
|
|
111
122
|
if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
|
|
112
123
|
continue;
|
|
113
124
|
const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
|
|
@@ -116,7 +127,7 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
|
116
127
|
const raw = readFileSync(filePath, 'utf8');
|
|
117
128
|
const parsed = JSON.parse(raw);
|
|
118
129
|
if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
|
|
119
|
-
return { skill: parsed.skill, callerId, sessionId, source: 'file' };
|
|
130
|
+
return { skill: parsed.skill, callerId, sessionId, mode: null, source: 'file' };
|
|
120
131
|
}
|
|
121
132
|
}
|
|
122
133
|
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
@@ -129,5 +140,5 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
|
129
140
|
// walk can still resolve a session id if the canonical resolver
|
|
130
141
|
// returns null (e.g. ad-hoc projects without a `.peaks/_runtime/session.json`).
|
|
131
142
|
void getCurrentSessionId;
|
|
132
|
-
return { skill: null, callerId: null, sessionId, source: 'none' };
|
|
143
|
+
return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
|
|
133
144
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { randomBytes } from 'node:crypto';
|
|
31
31
|
import { posix as path } from 'node:path';
|
|
32
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
32
33
|
export const DEFAULT_TTL_BY_ROLE = Object.freeze({
|
|
33
34
|
rd: 30 * 60 * 1_000,
|
|
34
35
|
qa: 15 * 60 * 1_000,
|
|
@@ -126,7 +127,7 @@ export function deserializeContainerLease(raw) {
|
|
|
126
127
|
function joinPath(...segments) {
|
|
127
128
|
if (segments.length === 0)
|
|
128
129
|
return '';
|
|
129
|
-
const normalized = segments.map((s) => s
|
|
130
|
+
const normalized = segments.map((s) => normalizePath(s));
|
|
130
131
|
let acc = normalized[0];
|
|
131
132
|
for (let i = 1; i < normalized.length; i++) {
|
|
132
133
|
acc = path.join(acc, normalized[i]);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* command; `peaks impact must-check` returns the must-check list
|
|
14
14
|
* subset. Persistence is per-invocation (no state file).
|
|
15
15
|
*/
|
|
16
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
16
17
|
/**
|
|
17
18
|
* Default business flow map (covers the most common B-end
|
|
18
19
|
* archetypes; users can override via --flows in the CLI).
|
|
@@ -34,7 +35,7 @@ export const DEFAULT_BUSINESS_FLOWS = new Map([
|
|
|
34
35
|
* (the file itself, the parent dir, the project-wide pattern).
|
|
35
36
|
*/
|
|
36
37
|
function pathToGlobs(path) {
|
|
37
|
-
const normalized = path
|
|
38
|
+
const normalized = normalizePath(path);
|
|
38
39
|
const parts = normalized.split('/');
|
|
39
40
|
// Drop the filename → get the parent pattern: "src/foo/" → "src/**"
|
|
40
41
|
const parent = parts.length > 1 ? parts.slice(0, -1).join('/') + '/**' : '**';
|
|
@@ -42,7 +43,7 @@ function pathToGlobs(path) {
|
|
|
42
43
|
}
|
|
43
44
|
/** Simple glob matcher supporting `**` and `*`. */
|
|
44
45
|
export function matchGlob(pattern, path) {
|
|
45
|
-
const normalizedPath = path
|
|
46
|
+
const normalizedPath = normalizePath(path);
|
|
46
47
|
// Build regex by splitting on glob tokens, escaping the rest.
|
|
47
48
|
// Order: ** (doublestar) → .* ; * → [^/]* ; other → escape
|
|
48
49
|
let regexStr = '';
|
|
@@ -108,7 +109,7 @@ export function runImpactScan(opts) {
|
|
|
108
109
|
// Predict other files that may be affected (siblings of changed files).
|
|
109
110
|
const impactedFiles = [];
|
|
110
111
|
for (const f of changed) {
|
|
111
|
-
const parts = f
|
|
112
|
+
const parts = normalizePath(f).split('/');
|
|
112
113
|
if (parts.length <= 1)
|
|
113
114
|
continue;
|
|
114
115
|
const parent = parts.slice(0, -1).join('/');
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join, relative } from 'node:path';
|
|
22
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
22
23
|
/**
|
|
23
24
|
* Skip-paths are matched against the path relative to `projectRoot`,
|
|
24
25
|
* forward-slashed (Windows-friendly). Each entry is a SUBSTRING —
|
|
@@ -41,7 +42,7 @@ function escapeRe(input) {
|
|
|
41
42
|
}
|
|
42
43
|
/** True iff `absPath` falls under any skip-path (substring match). */
|
|
43
44
|
export function shouldSkip(projectRoot, absPath) {
|
|
44
|
-
const rel = relative(projectRoot, absPath)
|
|
45
|
+
const rel = normalizePath(relative(projectRoot, absPath));
|
|
45
46
|
return SKIP_DIRS.some((skip) => rel.includes(skip));
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { posix, relative, resolve, isAbsolute as nodeIsAbsolute } from 'node:path';
|
|
24
24
|
import { isPathInsideArtifactRoot as _isPathInsideArtifactRoot } from '../../shared/path-safety.js';
|
|
25
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
25
26
|
export function ok(value) {
|
|
26
27
|
return { ok: true, value };
|
|
27
28
|
}
|
|
@@ -111,7 +112,7 @@ export function planArtifactPath(input) {
|
|
|
111
112
|
// Always normalize separators to forward-slashes for the JSON-safe output,
|
|
112
113
|
// then run posix.normalize so empty segments (foo//bar) and `..` segments
|
|
113
114
|
// collapse to canonical form.
|
|
114
|
-
const forwardSlashed = candidateRaw
|
|
115
|
+
const forwardSlashed = normalizePath(candidateRaw);
|
|
115
116
|
const normalized = posix.normalize(forwardSlashed);
|
|
116
117
|
// Resolve against workspaceRoot. If the candidate is already absolute,
|
|
117
118
|
// posix.normalize preserves the leading `/`; `resolve` keeps it absolute
|
|
@@ -129,7 +130,7 @@ export function planArtifactPath(input) {
|
|
|
129
130
|
});
|
|
130
131
|
}
|
|
131
132
|
const relativePath = relative(workspaceRoot, absolutePath);
|
|
132
|
-
const jsonSafeRelativePath = relativePath
|
|
133
|
+
const jsonSafeRelativePath = normalizePath(relativePath);
|
|
133
134
|
return ok({ absolutePath, relativePath, jsonSafeRelativePath });
|
|
134
135
|
}
|
|
135
136
|
const WORKSPACE_UNAVAILABLE_NEXT_ACTIONS = [
|
|
@@ -24,6 +24,7 @@ import { posix, relative, resolve } from 'node:path';
|
|
|
24
24
|
import { readFile, stat } from 'node:fs/promises';
|
|
25
25
|
import { isDirectory } from 'peaks-loop-shared/fs';
|
|
26
26
|
import { ok, err } from './artifact-boundary.js';
|
|
27
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Discovery
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
@@ -63,7 +64,7 @@ export async function resolveCoverageSummaryPath(input) {
|
|
|
63
64
|
return err({ code: 'missing', triedPaths });
|
|
64
65
|
}
|
|
65
66
|
function toPosix(p) {
|
|
66
|
-
return p
|
|
67
|
+
return normalizePath(p).replace(/\/$/, '');
|
|
67
68
|
}
|
|
68
69
|
async function isReadable(p) {
|
|
69
70
|
try {
|
|
@@ -112,7 +113,7 @@ export async function readC8Summary(path) {
|
|
|
112
113
|
if (lines === null || statements === null || functions === null || branches === null) {
|
|
113
114
|
continue;
|
|
114
115
|
}
|
|
115
|
-
const normalizedKey = posix.normalize(key
|
|
116
|
+
const normalizedKey = posix.normalize(normalizePath(key));
|
|
116
117
|
files.set(normalizedKey, {
|
|
117
118
|
path: normalizePathSep(normalizedKey),
|
|
118
119
|
relativePath: normalizedKey,
|
|
@@ -141,7 +142,7 @@ function asMetric(input) {
|
|
|
141
142
|
return { pct, covered, total };
|
|
142
143
|
}
|
|
143
144
|
function normalizePathSep(p) {
|
|
144
|
-
return p
|
|
145
|
+
return normalizePath(p);
|
|
145
146
|
}
|
|
146
147
|
// ---------------------------------------------------------------------------
|
|
147
148
|
// Capability Mapping block parser (proposal.md)
|
|
@@ -215,7 +216,7 @@ function parseCapabilityRow(line) {
|
|
|
215
216
|
return null;
|
|
216
217
|
const row = {
|
|
217
218
|
capability,
|
|
218
|
-
source: source
|
|
219
|
+
source: normalizePath(source).replace(/^\.\//, '').replace(/\/$/, ''),
|
|
219
220
|
};
|
|
220
221
|
if (testAnchor !== undefined && testAnchor !== '') {
|
|
221
222
|
row.testAnchor = testAnchor;
|
|
@@ -236,7 +237,7 @@ function parseCapabilityRow(line) {
|
|
|
236
237
|
export async function resolveCapabilityFiles(input) {
|
|
237
238
|
const abs = resolve(input.projectRoot, input.row.source);
|
|
238
239
|
if (!(await isDirectory(abs))) {
|
|
239
|
-
const rel = relative(input.projectRoot, abs)
|
|
240
|
+
const rel = normalizePath(relative(input.projectRoot, abs));
|
|
240
241
|
return [rel];
|
|
241
242
|
}
|
|
242
243
|
return await walkTsFiles(abs, input.projectRoot);
|
|
@@ -261,7 +262,7 @@ async function walkTsFiles(absRoot, projectRoot) {
|
|
|
261
262
|
stack.push(full);
|
|
262
263
|
}
|
|
263
264
|
else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
264
|
-
const rel = relative(projectRoot, full)
|
|
265
|
+
const rel = normalizePath(relative(projectRoot, full));
|
|
265
266
|
out.push(rel);
|
|
266
267
|
}
|
|
267
268
|
}
|
|
@@ -339,7 +340,7 @@ export async function validateCapabilityCoverage(input) {
|
|
|
339
340
|
export async function findStaleChangeFiles(input) {
|
|
340
341
|
const { stat: statFn } = await import('node:fs/promises');
|
|
341
342
|
const summaryMtime = (await statFn(input.summary.path)).mtimeMs;
|
|
342
|
-
const changeRoot = `${input.openspecRoot
|
|
343
|
+
const changeRoot = `${normalizePath(input.openspecRoot).replace(/\/$/, '')}/changes/${input.changeId}`;
|
|
343
344
|
const candidates = [
|
|
344
345
|
`${changeRoot}/proposal.md`,
|
|
345
346
|
`${changeRoot}/tasks.md`,
|
|
@@ -375,7 +376,7 @@ export async function findStaleChangeFiles(input) {
|
|
|
375
376
|
try {
|
|
376
377
|
const m = (await statFn(candidate)).mtimeMs;
|
|
377
378
|
if (m > summaryMtime) {
|
|
378
|
-
stale.push(relative(input.projectRoot, candidate)
|
|
379
|
+
stale.push(normalizePath(relative(input.projectRoot, candidate)));
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
382
|
catch {
|
|
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
|
|
18
18
|
import { sha256OfBody } from './handoff-service.js';
|
|
19
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
19
20
|
/**
|
|
20
21
|
* Auto-regen the prd/handoff.md under `.peaks/_runtime/<sid>/prd/`.
|
|
21
22
|
* Returns the outcome; never throws.
|
|
@@ -58,7 +59,7 @@ export async function autoRegenPrdHandoff(opts) {
|
|
|
58
59
|
'goals: []',
|
|
59
60
|
'acceptanceCriteria: []',
|
|
60
61
|
'preservedBehavior: []',
|
|
61
|
-
`handoffPath: ${handoffPath.replace(opts.projectRoot, '')
|
|
62
|
+
`handoffPath: ${normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')}`,
|
|
62
63
|
'---',
|
|
63
64
|
''
|
|
64
65
|
].join('\n');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { extname, basename } from 'node:path';
|
|
3
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
3
4
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.scala', '.dart', '.less', '.scss', '.sass', '.css']);
|
|
4
5
|
const DOCS_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']);
|
|
5
6
|
const LOCKFILE_NAMES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb', 'Cargo.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'poetry.lock']);
|
|
@@ -32,7 +33,7 @@ function classifyFile(filePath) {
|
|
|
32
33
|
* wrote `.peaks/**` markdown would be misclassified as a docs change.
|
|
33
34
|
*/
|
|
34
35
|
function isArtifactWorkspaceFile(filePath) {
|
|
35
|
-
const normalized = filePath
|
|
36
|
+
const normalized = normalizePath(filePath);
|
|
36
37
|
return normalized === '.peaks' || normalized.startsWith('.peaks/');
|
|
37
38
|
}
|
|
38
39
|
function tryGitDiffFiles(projectRoot, baseRef) {
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* Body of every function moved verbatim per Karpathy #3 (Surgical
|
|
19
19
|
* Changes). No behavior change. The bridge adds nothing of its own.
|
|
20
20
|
*/
|
|
21
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
22
|
-
import { dirname, join
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
23
|
import { randomBytes } from 'node:crypto';
|
|
24
24
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
25
|
+
import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
|
|
25
26
|
import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding } from './session-manager.js';
|
|
26
27
|
// --- Lower-level helpers the bridge needs (moved verbatim) ---
|
|
27
28
|
const SESSION_FILE = join('_runtime', 'session.json');
|
|
@@ -30,18 +31,6 @@ const META_FILE = 'session.json';
|
|
|
30
31
|
function getLegacySessionFilePath(projectRoot) {
|
|
31
32
|
return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
|
|
32
33
|
}
|
|
33
|
-
function canonicalizeProjectRoot(p) {
|
|
34
|
-
try {
|
|
35
|
-
return realpathSync(p);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return resolve(p);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
function resolveStoredAgainstCaller(stored, caller) {
|
|
42
|
-
const resolved = resolve(caller, stored);
|
|
43
|
-
return canonicalizeProjectRoot(resolved);
|
|
44
|
-
}
|
|
45
34
|
function generateSessionId() {
|
|
46
35
|
const now = new Date();
|
|
47
36
|
const year = now.getFullYear();
|
|
@@ -62,7 +51,9 @@ function readSessionFile(projectRoot) {
|
|
|
62
51
|
return null;
|
|
63
52
|
try {
|
|
64
53
|
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
65
|
-
if (data.sessionId &&
|
|
54
|
+
if (data.sessionId &&
|
|
55
|
+
typeof data.projectRoot === 'string' &&
|
|
56
|
+
projectRootsMatch(data.projectRoot, projectRoot)) {
|
|
66
57
|
return data;
|
|
67
58
|
}
|
|
68
59
|
return null;
|
|
@@ -79,10 +70,9 @@ function readSessionFileCanonical(projectRoot) {
|
|
|
79
70
|
return null;
|
|
80
71
|
try {
|
|
81
72
|
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
82
|
-
const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
|
|
83
73
|
if (data.sessionId &&
|
|
84
|
-
|
|
85
|
-
|
|
74
|
+
typeof data.projectRoot === 'string' &&
|
|
75
|
+
projectRootsMatch(data.projectRoot, projectRoot)) {
|
|
86
76
|
return data;
|
|
87
77
|
}
|
|
88
78
|
return null;
|
|
@@ -97,7 +87,15 @@ function writeSessionFile(projectRoot, info) {
|
|
|
97
87
|
if (!existsSync(dir)) {
|
|
98
88
|
mkdirSync(dir, { recursive: true });
|
|
99
89
|
}
|
|
100
|
-
|
|
90
|
+
let canonicalProjectRoot;
|
|
91
|
+
try {
|
|
92
|
+
canonicalProjectRoot = stableRealPath(info.projectRoot);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
canonicalProjectRoot = info.projectRoot;
|
|
96
|
+
}
|
|
97
|
+
const canonicalInfo = { ...info, projectRoot: canonicalProjectRoot };
|
|
98
|
+
writeFileSync(sessionFile, JSON.stringify(canonicalInfo, null, 2), 'utf8');
|
|
101
99
|
}
|
|
102
100
|
function getMetaFilePath(projectRoot, sessionId) {
|
|
103
101
|
return join(projectRoot, '.peaks', '_runtime', sessionId, META_FILE);
|
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkS
|
|
|
9
9
|
import { mkdir as mkdirAsync } from 'node:fs/promises';
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
|
|
12
13
|
import { ensureSession } from './session-binding-bridge.js';
|
|
13
14
|
// As of slice 2026-06-05-peaks-runtime-layer the project-level session
|
|
14
15
|
// binding lives under `.peaks/_runtime/session.json`. The legacy
|
|
@@ -89,17 +90,22 @@ function getSessionFilePath(projectRoot) {
|
|
|
89
90
|
* Read existing session info from disk.
|
|
90
91
|
* Returns null if no session file exists or if it's invalid.
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* The `projectRoot` comparison is canonicalized (separator, symlink,
|
|
94
|
+
* and — on Windows only — case) via `projectRootsMatch`. Before
|
|
95
|
+
* slice `2026-08-04-rid-001-path-canonicalize` this was a strict
|
|
96
|
+
* `===`, which returned null whenever the stored form differed
|
|
97
|
+
* cosmetically from the caller-passed form. On Windows Git Bash that
|
|
98
|
+
* is the common case: `peaks workspace init` stores
|
|
99
|
+
* `C:\Users\...\peaks-loop` while the caller passes
|
|
100
|
+
* `C:/Users/.../peaks-loop`, so every `getSessionId` returned null and
|
|
101
|
+
* `presence:set` failed closed with `PEAKS_SESSION_NOT_BOUND` —
|
|
102
|
+
* surfacing to the user as a permanent `peaks empty` statusline.
|
|
98
103
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
104
|
+
* Canonicalization is a strict widening: paths that denote the same
|
|
105
|
+
* physical directory now match, and paths that denote different
|
|
106
|
+
* directories still do not (pinned by the Case 4 regression test in
|
|
107
|
+
* `tests/unit/session/session-manager-path-canonicalize.test.ts`), so
|
|
108
|
+
* the "no session bound" code path other modules depend on is intact.
|
|
103
109
|
*/
|
|
104
110
|
function readSessionFile(projectRoot) {
|
|
105
111
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
@@ -112,7 +118,7 @@ function readSessionFile(projectRoot) {
|
|
|
112
118
|
return null;
|
|
113
119
|
try {
|
|
114
120
|
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
115
|
-
if (data.sessionId && data.projectRoot === projectRoot) {
|
|
121
|
+
if (data.sessionId && typeof data.projectRoot === 'string' && projectRootsMatch(data.projectRoot, projectRoot)) {
|
|
116
122
|
return data;
|
|
117
123
|
}
|
|
118
124
|
return null;
|
|
@@ -157,6 +163,13 @@ function readSessionFileCanonical(projectRoot) {
|
|
|
157
163
|
* `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
|
|
158
164
|
* created on demand. The legacy `.peaks/.session.json` is NOT written by
|
|
159
165
|
* this slice; it is only read for back-compat.
|
|
166
|
+
*
|
|
167
|
+
* The persisted `projectRoot` is passed through `stableRealPath` so the
|
|
168
|
+
* stored form is symlink-resolved and stable across callers. We store
|
|
169
|
+
* the REAL path, not the `projectRootCompareKey` — the key is
|
|
170
|
+
* lossy (lower-cased on Windows) and is only ever a comparison
|
|
171
|
+
* artifact. Reads tolerate either form via `projectRootsMatch`, so
|
|
172
|
+
* bindings written by older versions keep resolving.
|
|
160
173
|
*/
|
|
161
174
|
function writeSessionFile(projectRoot, info) {
|
|
162
175
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
@@ -164,7 +177,18 @@ function writeSessionFile(projectRoot, info) {
|
|
|
164
177
|
if (!existsSync(dir)) {
|
|
165
178
|
mkdirSync(dir, { recursive: true });
|
|
166
179
|
}
|
|
167
|
-
|
|
180
|
+
let canonicalProjectRoot;
|
|
181
|
+
try {
|
|
182
|
+
canonicalProjectRoot = stableRealPath(info.projectRoot);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Never block a write on canonicalization: if the path cannot be
|
|
186
|
+
// realpath'd, persist the caller's form unchanged. Reads canonicalize
|
|
187
|
+
// both sides anyway, so a non-canonical stored value still matches.
|
|
188
|
+
canonicalProjectRoot = info.projectRoot;
|
|
189
|
+
}
|
|
190
|
+
const canonicalInfo = { ...info, projectRoot: canonicalProjectRoot };
|
|
191
|
+
writeFileSync(sessionFile, JSON.stringify(canonicalInfo, null, 2), 'utf8');
|
|
168
192
|
}
|
|
169
193
|
/**
|
|
170
194
|
* Drop the project-level session binding at the canonical
|
|
@@ -137,26 +137,7 @@ function buildPalette(capability, noColor) {
|
|
|
137
137
|
}
|
|
138
138
|
const BREATHING_GLYPHS_UNICODE = ['●', '◐', '◑', '◒', '◓'];
|
|
139
139
|
const BREATHING_GLYPHS_ASCII = ['*', 'o', '+', '~', '|'];
|
|
140
|
-
const BREATHING_PERIOD_MS =
|
|
141
|
-
/**
|
|
142
|
-
* Bee-tier (1-level sub-role) skills and their full orchestrator
|
|
143
|
-
* parent name. The terminal status line shows both layers when a bee
|
|
144
|
-
* is active: the active bee name plus a `↑<parent-full>` marker
|
|
145
|
-
* pointing at the dispatching orchestrator.
|
|
146
|
-
*/
|
|
147
|
-
const BEE_TO_PARENT = {
|
|
148
|
-
'peaks-prd': 'peaks-code',
|
|
149
|
-
'peaks-rd': 'peaks-code',
|
|
150
|
-
'peaks-qa': 'peaks-code',
|
|
151
|
-
'peaks-ui': 'peaks-code',
|
|
152
|
-
'peaks-sc': 'peaks-code',
|
|
153
|
-
'peaks-txt': 'peaks-code',
|
|
154
|
-
'peaks-final-review': 'peaks-code',
|
|
155
|
-
'peaks-resume': 'peaks-code',
|
|
156
|
-
'peaks-status': 'peaks-code',
|
|
157
|
-
'peaks-test': 'peaks-code',
|
|
158
|
-
'peaks-reviewer': 'peaks-code',
|
|
159
|
-
};
|
|
140
|
+
const BREATHING_PERIOD_MS = 600;
|
|
160
141
|
function pickBreathingGlyph(capability, nowMs) {
|
|
161
142
|
const set = capability === 'ascii' ? BREATHING_GLYPHS_ASCII : BREATHING_GLYPHS_UNICODE;
|
|
162
143
|
const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
|
|
@@ -210,8 +191,23 @@ function rootLabel(projectRoot) {
|
|
|
210
191
|
* prefix and the project root label. Kept separate so the token layout
|
|
211
192
|
* is obvious at the call site and so each state has a single
|
|
212
193
|
* responsibility.
|
|
194
|
+
*
|
|
195
|
+
* Active-leaf rendering (slice 2026-08-04-rid-005-statusline-dual-skill):
|
|
196
|
+
* when the model carries an `activeLeaf` (an in-flight bee dispatch under
|
|
197
|
+
* the orchestrator), the line surfaces the leaf role alongside the
|
|
198
|
+
* orchestrator skill. The render priorities are:
|
|
199
|
+
*
|
|
200
|
+
* - activeLeaf === null → `${skill}` (current behavior)
|
|
201
|
+
* - activeLeaf.pendingCount === 1 → `${leaf} | ${skill}`
|
|
202
|
+
* - activeLeaf.pendingCount > 1 → `${leaf} (+${N-1}) | ${skill}`
|
|
203
|
+
*
|
|
204
|
+
* The orchestrator skill itself is rendered with its mode token; the leaf
|
|
205
|
+
* role is rendered without a mode (the leaf does not own the mode state —
|
|
206
|
+
* the orchestrator does). The 14→1 bee skill mapping that previously
|
|
207
|
+
* forced every bee role to render with a `↑<parent>` marker was removed
|
|
208
|
+
* in this slice; the dual-skill layout above replaces it.
|
|
213
209
|
*/
|
|
214
|
-
function renderActive(presence, palette, nowMs, capability, noColor) {
|
|
210
|
+
function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf) {
|
|
215
211
|
if (!presence) {
|
|
216
212
|
return `${palette.idle} ${palette.idleLabel}`;
|
|
217
213
|
}
|
|
@@ -222,16 +218,17 @@ function renderActive(presence, palette, nowMs, capability, noColor) {
|
|
|
222
218
|
}
|
|
223
219
|
const skill = presence.skill;
|
|
224
220
|
const dot = renderActiveDot(capability, nowMs, noColor);
|
|
225
|
-
const beeParent = BEE_TO_PARENT[skill];
|
|
226
221
|
const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
|
|
227
222
|
? brandRun(` [${presence.mode}]`, noColor, capability)
|
|
228
223
|
: '';
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
224
|
+
// Dual-skill layout: leaf role (in-flight bee) + orchestrator skill.
|
|
225
|
+
if (activeLeaf !== null) {
|
|
226
|
+
const leaf = brandRun(activeLeaf.role, noColor, capability);
|
|
227
|
+
const tail = activeLeaf.pendingCount > 1
|
|
228
|
+
? ` ${brandRun(`(+${activeLeaf.pendingCount - 1})`, noColor, capability)}`
|
|
229
|
+
: '';
|
|
230
|
+
const sep = brandRun(' | ', noColor, capability);
|
|
231
|
+
return `${dot} ${leaf}${tail}${sep}${brandRun(skill, noColor, capability)}${modeToken}`;
|
|
235
232
|
}
|
|
236
233
|
return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
|
|
237
234
|
}
|
|
@@ -401,7 +398,7 @@ export function isNoColorEnv(env) {
|
|
|
401
398
|
}
|
|
402
399
|
/**
|
|
403
400
|
* Marquee scan band — a single-pass light band that sweeps left ↔ right
|
|
404
|
-
* across the entire status line on a
|
|
401
|
+
* across the entire status line on a 0.4 s round trip. The band's
|
|
405
402
|
* foreground color is `#E0E0E0` (off-white) with `1;` (bold) — see
|
|
406
403
|
* {@link HIGHLIGHT_SGR_OPEN}. Cells OUTSIDE the band keep their
|
|
407
404
|
* original SGR (brand purple or semantic warning/failed); only cells
|
|
@@ -430,8 +427,8 @@ export function isNoColorEnv(env) {
|
|
|
430
427
|
*
|
|
431
428
|
* ASCII tier: skipped — there are no SGR codes to inject.
|
|
432
429
|
*/
|
|
433
|
-
const MARQUEE_PERIOD_MS =
|
|
434
|
-
const MARQUEE_BAND_WIDTH =
|
|
430
|
+
const MARQUEE_PERIOD_MS = 400;
|
|
431
|
+
const MARQUEE_BAND_WIDTH = 2;
|
|
435
432
|
/**
|
|
436
433
|
* Visible-character width of an ANSI-bearing string. Skips every
|
|
437
434
|
* `\x1b[...m` escape so the count reflects what the terminal paints,
|
|
@@ -590,7 +587,7 @@ export function renderStatusLine(model, options, env) {
|
|
|
590
587
|
else {
|
|
591
588
|
switch (model.state) {
|
|
592
589
|
case 'active':
|
|
593
|
-
line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor)}${rootSuffix}`;
|
|
590
|
+
line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf)}${rootSuffix}`;
|
|
594
591
|
break;
|
|
595
592
|
case 'stale':
|
|
596
593
|
line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
|
|
@@ -6,6 +6,7 @@ export type StatusLineStdin = {
|
|
|
6
6
|
};
|
|
7
7
|
cwd?: string;
|
|
8
8
|
session_id?: string;
|
|
9
|
+
caller_id?: string;
|
|
9
10
|
};
|
|
10
11
|
export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
|
|
11
12
|
export type StatusLinePresence = {
|
|
@@ -15,12 +16,17 @@ export type StatusLinePresence = {
|
|
|
15
16
|
setAt?: string;
|
|
16
17
|
claudeSessionId?: string;
|
|
17
18
|
};
|
|
19
|
+
export type StatusLineActiveLeaf = {
|
|
20
|
+
role: string;
|
|
21
|
+
pendingCount: number;
|
|
22
|
+
};
|
|
18
23
|
export type StatusLineModel = {
|
|
19
24
|
state: StatusLineState;
|
|
20
25
|
projectRoot: string | null;
|
|
21
26
|
presence: StatusLinePresence | null;
|
|
22
27
|
ageMs: number | null;
|
|
23
28
|
compact: CompactStatuslineState;
|
|
29
|
+
activeLeaf: StatusLineActiveLeaf | null;
|
|
24
30
|
};
|
|
25
31
|
export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
|
|
26
32
|
export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
|
|
@@ -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": "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"
|