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.
Files changed (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/baseline-commands.d.ts +3 -0
  4. package/dist/cli/commands/baseline-commands.js +146 -0
  5. package/dist/cli/commands/container-commands.js +2 -1
  6. package/dist/cli/commands/core/skill-command.js +32 -5
  7. package/dist/cli/commands/openspec-commands.js +2 -1
  8. package/dist/cli/commands/statusline-commands.js +1 -1
  9. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
  10. package/dist/services/audit/enforcers/active-skill-resolver.js +50 -39
  11. package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
  12. package/dist/services/capability-audit-service/cross-check.js +11 -0
  13. package/dist/services/capability-audit-service/index.d.ts +3 -0
  14. package/dist/services/capability-audit-service/index.js +2 -0
  15. package/dist/services/capability-audit-service/runner.d.ts +27 -0
  16. package/dist/services/capability-audit-service/runner.js +40 -0
  17. package/dist/services/capability-audit-service/staleness.d.ts +1 -0
  18. package/dist/services/capability-audit-service/staleness.js +7 -0
  19. package/dist/services/capability-audit-service/types.d.ts +24 -0
  20. package/dist/services/capability-audit-service/types.js +1 -0
  21. package/dist/services/capability-baseline/store.d.ts +33 -0
  22. package/dist/services/capability-baseline/store.js +80 -0
  23. package/dist/services/capability-baseline/types.d.ts +45 -0
  24. package/dist/services/capability-baseline/types.js +5 -0
  25. package/dist/services/capability-baseline/validator.d.ts +19 -0
  26. package/dist/services/capability-baseline/validator.js +38 -0
  27. package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
  28. package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
  29. package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
  30. package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
  31. package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
  32. package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
  33. package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
  34. package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
  35. package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
  36. package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
  37. package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
  38. package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
  39. package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
  40. package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
  41. package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
  42. package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
  43. package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
  44. package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
  45. package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
  46. package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
  47. package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
  48. package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
  49. package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
  50. package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
  51. package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
  52. package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
  53. package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
  54. package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
  55. package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
  56. package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
  57. package/dist/services/capability-guard-runner/diff.d.ts +2 -0
  58. package/dist/services/capability-guard-runner/diff.js +7 -0
  59. package/dist/services/capability-guard-runner/runner.d.ts +9 -0
  60. package/dist/services/capability-guard-runner/runner.js +22 -0
  61. package/dist/services/capability-guard-runner/types.d.ts +39 -0
  62. package/dist/services/capability-guard-runner/types.js +1 -0
  63. package/dist/services/container/container-lease.js +2 -1
  64. package/dist/services/evolution/evolution-types.d.ts +16 -16
  65. package/dist/services/final-review/final-review-service.d.ts +8 -0
  66. package/dist/services/final-review/final-review-service.js +14 -0
  67. package/dist/services/final-review/final-review-types.d.ts +1 -1
  68. package/dist/services/impact/impact-scan-service.js +4 -3
  69. package/dist/services/job/job-progress-store.d.ts +2 -2
  70. package/dist/services/job/job-types.d.ts +26 -26
  71. package/dist/services/migrate-skill-name/migrate.js +2 -1
  72. package/dist/services/observability/observability-service.d.ts +2 -2
  73. package/dist/services/openspec/artifact-boundary.js +3 -2
  74. package/dist/services/openspec/coverage-evidence-reader.js +9 -8
  75. package/dist/services/prd/handoff-auto-regen.js +2 -1
  76. package/dist/services/scan/type-sanity-service.js +2 -1
  77. package/dist/services/sediment/json-schema.d.ts +4 -4
  78. package/dist/services/session/session-binding-bridge.js +17 -19
  79. package/dist/services/session/session-manager.js +36 -12
  80. package/dist/services/session/session-resume-service.d.ts +4 -0
  81. package/dist/services/session/session-resume-service.js +4 -0
  82. package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
  83. package/dist/services/skills/skill-statusline-renderer.js +335 -126
  84. package/dist/services/skills/skill-statusline-service.d.ts +6 -0
  85. package/dist/services/skills/skill-statusline-service.js +105 -7
  86. package/dist/services/vm/vm-lease.js +2 -1
  87. package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
  88. package/dist/services/workspace/workspace-service.js +2 -1
  89. package/dist/services/worktree/worktree-lease.js +2 -1
  90. package/dist/shared/path-safety.js +3 -5
  91. package/dist/shared/path-utils.d.ts +48 -0
  92. package/dist/shared/path-utils.js +65 -1
  93. 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
- const { presence, invalid } = readPresenceReadOnly(projectRoot);
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
- return { state, projectRoot, presence, ageMs, compact };
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.replace(/\\/g, '/'));
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.replace(/\\/g, '/').replace(/^\/+/, '');
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.replace(/\\/g, '/').replace(/^\/+/, '');
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.replace(/\\/g, '/');
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.replace(/\\/g, '/'));
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
- function normalizeForwardSlashes(input) {
14
- return input.replace(/\\/g, '/');
15
- }
13
+ import { normalizePath } from './path-utils.js';
16
14
  function hasUnsafePathShape(input) {
17
- const normalized = normalizeForwardSlashes(input);
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(normalizeForwardSlashes(input));
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.8",
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-mut": "0.1.14",
102
- "peaks-loop-shared-channel": "0.0.18",
103
- "peaks-loop-shared": "0.0.39"
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",