peaks-loop 4.0.14 → 4.0.16
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 +62 -0
- package/config/eslint/.peaks-rules.cjs +8 -1
- package/dist/cli/commands/_register.js +2 -1
- package/dist/cli/commands/code-review-commands.d.ts +2 -30
- package/dist/cli/commands/code-review-commands.js +43 -65
- package/dist/cli/commands/lint-commands.d.ts +21 -0
- package/dist/cli/commands/lint-commands.js +146 -0
- package/dist/cli/commands/outer-cache-commands.js +13 -6
- package/dist/cli/commands/security-audit-commands.d.ts +1 -1
- package/dist/cli/commands/security-audit-commands.js +1 -1
- package/dist/services/code-review/ecc-bridge.d.ts +2 -7
- package/dist/services/config/config-service.d.ts +2 -19
- package/dist/services/config/config-service.js +0 -52
- package/dist/services/config/config-types.d.ts +4 -24
- package/dist/services/config/config-types.js +1 -10
- package/dist/services/lint/detect-eslint.d.ts +10 -0
- package/dist/services/lint/detect-eslint.js +59 -0
- package/dist/services/lint/detect-ocr-18.d.ts +10 -0
- package/dist/services/lint/detect-ocr-18.js +40 -0
- package/dist/services/lint/eslint-runner.d.ts +58 -0
- package/dist/services/lint/eslint-runner.js +307 -0
- package/dist/services/lint/ocr-multilang-adapter.d.ts +34 -0
- package/dist/services/lint/ocr-multilang-adapter.js +143 -0
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/session/caller-binding-service.d.ts +7 -0
- package/dist/services/session/caller-binding-service.js +11 -7
- package/dist/services/session/session-binding-bridge.d.ts +17 -1
- package/dist/services/session/session-binding-bridge.js +185 -29
- package/dist/services/session/session-manager.js +37 -0
- package/dist/services/skills/skill-presence-service.js +13 -1
- package/package.json +5 -12
- package/skills/bee/peaks-rd/SKILL.md +1 -1
- package/skills/bee/peaks-rd/references/jsts-eslint-gate.md +197 -0
- package/skills/bee/peaks-rd/references/ocr-multilang-1.8.md +132 -0
- package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
- package/skills/peaks-code/SKILL.md +1 -1
- package/dist/services/code-review/ocr-service.d.ts +0 -129
- package/dist/services/code-review/ocr-service.js +0 -372
- package/skills/bee/peaks-rd/references/ocr-integration.md +0 -229
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCR 1.8.x multi-language reviewer adapter. Routes one of the
|
|
3
|
+
* 8 supported languages to the corresponding `ocr review` filter.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
export const OCR_18_PACKAGE = '@alibaba-group/open-code-review@1.8.9';
|
|
7
|
+
export const OCR_18_LANGUAGES = [
|
|
8
|
+
'python',
|
|
9
|
+
'go',
|
|
10
|
+
'java',
|
|
11
|
+
'rust',
|
|
12
|
+
'cpp',
|
|
13
|
+
'csharp',
|
|
14
|
+
'ruby',
|
|
15
|
+
'php'
|
|
16
|
+
];
|
|
17
|
+
function isSupportedLanguage(value) {
|
|
18
|
+
return OCR_18_LANGUAGES.includes(value);
|
|
19
|
+
}
|
|
20
|
+
function severityFor(value) {
|
|
21
|
+
if (value === 'error' || value === 'critical' || value === 2)
|
|
22
|
+
return 'error';
|
|
23
|
+
if (value === 'warn' || value === 'warning' || value === 1)
|
|
24
|
+
return 'warn';
|
|
25
|
+
return 'info';
|
|
26
|
+
}
|
|
27
|
+
function emptyByLanguage() {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const lang of OCR_18_LANGUAGES) {
|
|
30
|
+
out[lang] = 0;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export function buildOcr18Args(options) {
|
|
35
|
+
if (!isSupportedLanguage(options.language)) {
|
|
36
|
+
throw Object.assign(new Error(`unsupported language: ${options.language}`), { code: 'LANGUAGE_UNSUPPORTED' });
|
|
37
|
+
}
|
|
38
|
+
const args = [
|
|
39
|
+
'--package', OCR_18_PACKAGE,
|
|
40
|
+
'--', 'ocr'
|
|
41
|
+
];
|
|
42
|
+
if (options.delegate === true) {
|
|
43
|
+
args.push('delegate', 'preview');
|
|
44
|
+
if (options.from !== undefined)
|
|
45
|
+
args.push('--from', options.from);
|
|
46
|
+
if (options.to !== undefined)
|
|
47
|
+
args.push('--to', options.to);
|
|
48
|
+
if (options.commit !== undefined)
|
|
49
|
+
args.push('--commit', options.commit);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
args.push('review', '--filter-language', options.language);
|
|
53
|
+
if (options.from !== undefined)
|
|
54
|
+
args.push('--from', options.from);
|
|
55
|
+
if (options.to !== undefined)
|
|
56
|
+
args.push('--to', options.to);
|
|
57
|
+
if (options.commit !== undefined)
|
|
58
|
+
args.push('--commit', options.commit);
|
|
59
|
+
args.push('--format', 'json');
|
|
60
|
+
}
|
|
61
|
+
return args;
|
|
62
|
+
}
|
|
63
|
+
export function runOcr18(options) {
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
if (!isSupportedLanguage(options.language)) {
|
|
66
|
+
return {
|
|
67
|
+
state: 'language-unsupported',
|
|
68
|
+
findings: [],
|
|
69
|
+
summary: null,
|
|
70
|
+
durationMs: Date.now() - start,
|
|
71
|
+
rawOutput: `unsupported language: ${options.language}`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const args = buildOcr18Args(options);
|
|
75
|
+
const spawnOptions = {
|
|
76
|
+
cwd: options.cwd,
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
timeout: options.timeoutMs ?? 60_000,
|
|
79
|
+
maxBuffer: 32 * 1024 * 1024
|
|
80
|
+
};
|
|
81
|
+
const result = spawnSync('npx', args, spawnOptions);
|
|
82
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
83
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr : '';
|
|
84
|
+
if (result.error !== undefined && result.error !== null) {
|
|
85
|
+
return {
|
|
86
|
+
state: 'ocr18-missing',
|
|
87
|
+
findings: [],
|
|
88
|
+
summary: null,
|
|
89
|
+
durationMs: Date.now() - start,
|
|
90
|
+
rawOutput: stderr || stdout
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (result.status !== 0) {
|
|
94
|
+
return {
|
|
95
|
+
state: 'execution-failed',
|
|
96
|
+
findings: [],
|
|
97
|
+
summary: null,
|
|
98
|
+
durationMs: Date.now() - start,
|
|
99
|
+
rawOutput: stderr || stdout
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
let findings = [];
|
|
103
|
+
if (stdout.trim().length > 0) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(stdout);
|
|
106
|
+
if (Array.isArray(parsed.findings)) {
|
|
107
|
+
for (const entry of parsed.findings) {
|
|
108
|
+
if (entry === null || typeof entry !== 'object')
|
|
109
|
+
continue;
|
|
110
|
+
const f = entry;
|
|
111
|
+
findings.push({
|
|
112
|
+
filePath: typeof f.file === 'string' ? f.file : '',
|
|
113
|
+
line: typeof f.line === 'number' ? f.line : 0,
|
|
114
|
+
ruleId: typeof f.rule === 'string' ? f.rule : null,
|
|
115
|
+
severity: severityFor(f.severity),
|
|
116
|
+
message: typeof f.message === 'string' ? f.message : ''
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return {
|
|
123
|
+
state: 'execution-failed',
|
|
124
|
+
findings: [],
|
|
125
|
+
summary: null,
|
|
126
|
+
durationMs: Date.now() - start,
|
|
127
|
+
rawOutput: stderr || stdout
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const bySeverity = { error: 0, warn: 0, info: 0 };
|
|
132
|
+
for (const f of findings)
|
|
133
|
+
bySeverity[f.severity]++;
|
|
134
|
+
const byLanguageMut = { ...emptyByLanguage() };
|
|
135
|
+
byLanguageMut[options.language] = findings.length;
|
|
136
|
+
return {
|
|
137
|
+
state: 'ok',
|
|
138
|
+
findings,
|
|
139
|
+
summary: { total: findings.length, bySeverity, byLanguage: byLanguageMut },
|
|
140
|
+
durationMs: Date.now() - start,
|
|
141
|
+
rawOutput: stdout
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -24,14 +24,14 @@ export declare const InstanceRecordSchema: z.ZodObject<{
|
|
|
24
24
|
callerId: z.ZodString;
|
|
25
25
|
lastHeartbeat: z.ZodString;
|
|
26
26
|
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
callerId: string;
|
|
27
28
|
lastHeartbeat: string;
|
|
28
29
|
startedAt: string;
|
|
29
30
|
roles: string[];
|
|
30
|
-
callerId: string;
|
|
31
31
|
}, {
|
|
32
|
+
callerId: string;
|
|
32
33
|
lastHeartbeat: string;
|
|
33
34
|
startedAt: string;
|
|
34
|
-
callerId: string;
|
|
35
35
|
roles?: string[] | undefined;
|
|
36
36
|
}>;
|
|
37
37
|
export declare const BindingSchema: z.ZodObject<{
|
|
@@ -45,14 +45,14 @@ export declare const BindingSchema: z.ZodObject<{
|
|
|
45
45
|
callerId: z.ZodString;
|
|
46
46
|
lastHeartbeat: z.ZodString;
|
|
47
47
|
}, "strip", z.ZodTypeAny, {
|
|
48
|
+
callerId: string;
|
|
48
49
|
lastHeartbeat: string;
|
|
49
50
|
startedAt: string;
|
|
50
51
|
roles: string[];
|
|
51
|
-
callerId: string;
|
|
52
52
|
}, {
|
|
53
|
+
callerId: string;
|
|
53
54
|
lastHeartbeat: string;
|
|
54
55
|
startedAt: string;
|
|
55
|
-
callerId: string;
|
|
56
56
|
roles?: string[] | undefined;
|
|
57
57
|
}>>;
|
|
58
58
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -61,10 +61,10 @@ export declare const BindingSchema: z.ZodObject<{
|
|
|
61
61
|
pid: number;
|
|
62
62
|
scope: string;
|
|
63
63
|
instances: Record<string, {
|
|
64
|
+
callerId: string;
|
|
64
65
|
lastHeartbeat: string;
|
|
65
66
|
startedAt: string;
|
|
66
67
|
roles: string[];
|
|
67
|
-
callerId: string;
|
|
68
68
|
}>;
|
|
69
69
|
}, {
|
|
70
70
|
lastHeartbeat: string;
|
|
@@ -72,9 +72,9 @@ export declare const BindingSchema: z.ZodObject<{
|
|
|
72
72
|
pid: number;
|
|
73
73
|
scope: string;
|
|
74
74
|
instances: Record<string, {
|
|
75
|
+
callerId: string;
|
|
75
76
|
lastHeartbeat: string;
|
|
76
77
|
startedAt: string;
|
|
77
|
-
callerId: string;
|
|
78
78
|
roles?: string[] | undefined;
|
|
79
79
|
}>;
|
|
80
80
|
}>;
|
|
@@ -59,6 +59,13 @@ export declare function getCallerBinding(projectRoot: string, callerId: string):
|
|
|
59
59
|
* for the binding object (callerId must match the file stem, peakSessionId
|
|
60
60
|
* must be a valid session id, projectRoot is canonicalized). Idempotent:
|
|
61
61
|
* re-writing the same callerId overwrites the file.
|
|
62
|
+
*
|
|
63
|
+
* Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
|
|
64
|
+
* (temp-file-then-rename) so a power-loss mid-write cannot leave the
|
|
65
|
+
* caller-binding file in a half-truncated state. `atomicWriteJson`
|
|
66
|
+
* owns its own `mkdirSync(dir, { recursive: true })`, so the inline
|
|
67
|
+
* mkdir block is removed. The previous `writeFileSync` path was a
|
|
68
|
+
* documented 4.0.14 carry-forward micro-fix from QA's issue #1.
|
|
62
69
|
*/
|
|
63
70
|
export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
|
|
64
71
|
/**
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
17
17
|
* for the freeze-in contract.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync,
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
|
-
import {
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
import { atomicWriteJson } from '../ide/shared/atomic-json.js';
|
|
22
23
|
import { CALLER_ID_REGEX } from './caller-id-types.js';
|
|
23
24
|
import { getSessionDir } from './getSessionDir.js';
|
|
24
25
|
/**
|
|
@@ -100,21 +101,24 @@ export function getCallerBinding(projectRoot, callerId) {
|
|
|
100
101
|
* for the binding object (callerId must match the file stem, peakSessionId
|
|
101
102
|
* must be a valid session id, projectRoot is canonicalized). Idempotent:
|
|
102
103
|
* re-writing the same callerId overwrites the file.
|
|
104
|
+
*
|
|
105
|
+
* Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
|
|
106
|
+
* (temp-file-then-rename) so a power-loss mid-write cannot leave the
|
|
107
|
+
* caller-binding file in a half-truncated state. `atomicWriteJson`
|
|
108
|
+
* owns its own `mkdirSync(dir, { recursive: true })`, so the inline
|
|
109
|
+
* mkdir block is removed. The previous `writeFileSync` path was a
|
|
110
|
+
* documented 4.0.14 carry-forward micro-fix from QA's issue #1.
|
|
103
111
|
*/
|
|
104
112
|
export function setCallerBinding(projectRoot, callerId, binding) {
|
|
105
113
|
if (binding.callerId !== callerId) {
|
|
106
114
|
throw new Error(`setCallerBinding: binding.callerId "${binding.callerId}" does not match callerId "${callerId}"`);
|
|
107
115
|
}
|
|
108
116
|
const bindingPath = getCallerBindingFile(projectRoot, callerId);
|
|
109
|
-
const dir = dirname(bindingPath);
|
|
110
|
-
if (!existsSync(dir)) {
|
|
111
|
-
mkdirSync(dir, { recursive: true });
|
|
112
|
-
}
|
|
113
117
|
const payload = {
|
|
114
118
|
...binding,
|
|
115
119
|
projectRoot: resolve(binding.projectRoot)
|
|
116
120
|
};
|
|
117
|
-
|
|
121
|
+
atomicWriteJson(bindingPath, payload);
|
|
118
122
|
}
|
|
119
123
|
/**
|
|
120
124
|
* Enumerate the per-caller binding files under
|
|
@@ -18,6 +18,12 @@
|
|
|
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
|
+
/**
|
|
22
|
+
* Test-only seam: reset the module-scoped `lastResolvedOuter` so
|
|
23
|
+
* individual tests start with a clean slate. NOT exported as part
|
|
24
|
+
* of the public API; marked TODO(g2) for the v2.14.0 grace window.
|
|
25
|
+
*/
|
|
26
|
+
export declare function _resetLastResolvedOuterForTest(): void;
|
|
21
27
|
export type EnsureSessionOptions = {
|
|
22
28
|
/**
|
|
23
29
|
* When `true`, suppress the outer-session-mismatch auto-rotation.
|
|
@@ -52,7 +58,7 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
|
52
58
|
* binding changes — and the rotation is surfaced in the return value
|
|
53
59
|
* so the CLI can include it in the JSON envelope.
|
|
54
60
|
*
|
|
55
|
-
* Rotation is suppressed in
|
|
61
|
+
* Rotation is suppressed in four cases (all false-positive guards):
|
|
56
62
|
*
|
|
57
63
|
* 1. The current outer session id is undefined (no env var set) —
|
|
58
64
|
* there is no signal to compare against, defaulting to "do not
|
|
@@ -63,6 +69,16 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
|
63
69
|
* 3. The bound session's recorded outer session id matches the
|
|
64
70
|
* current one (reconnect within the same Claude session) — this
|
|
65
71
|
* is the common case, not a swap.
|
|
72
|
+
* 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
|
|
73
|
+
* `lastResolvedOuter.value` was already set to `currentOuterSessionId`
|
|
74
|
+
* in this process and `boundOuter` equals that same value. Long-
|
|
75
|
+
* running presence-lease writers that re-resolve within a single
|
|
76
|
+
* process must not rotate on every CLI heartbeat. The module-scoped
|
|
77
|
+
* state is per-process; each fresh CLI invocation starts with
|
|
78
|
+
* `lastResolvedOuter === null`, so this guard cannot leak across
|
|
79
|
+
* processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
|
|
80
|
+
* is a defined string (an undefined last-resolved value still
|
|
81
|
+
* falls through to the legacy comparison path).
|
|
66
82
|
*
|
|
67
83
|
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
68
84
|
* check is short-circuited and the binding is preserved (opt-out for
|
|
@@ -23,6 +23,8 @@ import { dirname, join } from 'node:path';
|
|
|
23
23
|
import { randomBytes } from 'node:crypto';
|
|
24
24
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
25
25
|
import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
|
|
26
|
+
import { getCallerBinding, setCallerBinding, } from './caller-binding-service.js';
|
|
27
|
+
import { resolveCallerProjection } from './resolve-caller-id.js';
|
|
26
28
|
import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding, setSessionMeta } from './session-manager.js';
|
|
27
29
|
// --- Lower-level helpers the bridge needs (moved verbatim) ---
|
|
28
30
|
const SESSION_FILE = join('_runtime', 'session.json');
|
|
@@ -131,42 +133,133 @@ function writeSessionMeta(projectRoot, sessionId, meta) {
|
|
|
131
133
|
}
|
|
132
134
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
133
135
|
}
|
|
136
|
+
// Slice 2026-08-06-session-cacde8-A.3: module-scoped state populated on
|
|
137
|
+
// every non-throw `getCurrentOuterSessionId` call. The 4th rotation
|
|
138
|
+
// guard in `ensureSessionWithRotation` short-circuits when both
|
|
139
|
+
// `currentOuterSessionId` and `boundOuter` equal the last-resolved
|
|
140
|
+
// value in this process — a common case for long-running presence-
|
|
141
|
+
// lease writers that re-resolve the outer session id within the same
|
|
142
|
+
// process. Each CLI invocation is a fresh process, so this guard is
|
|
143
|
+
// per-invocation and cannot leak across processes.
|
|
144
|
+
let lastResolvedOuter = null;
|
|
134
145
|
function getCurrentOuterSessionId(projectRoot) {
|
|
146
|
+
let resolved;
|
|
135
147
|
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
136
|
-
if (typeof peaks === 'string' && peaks.length > 0)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
if (typeof peaks === 'string' && peaks.length > 0) {
|
|
149
|
+
resolved = peaks;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const claude = process.env.CLAUDE_CODE_SESSION_ID;
|
|
153
|
+
if (typeof claude === 'string' && claude.length > 0) {
|
|
154
|
+
resolved = claude;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Slice 2026-08-06-session-outer-cache (G1): when the peaks CLI runs
|
|
158
|
+
// as a sub-process of Claude Code (or any IDE that does not export
|
|
159
|
+
// CLAUDE_CODE_SESSION_ID into the child env), the env vars above are
|
|
160
|
+
// undefined. Fall back to the per-project file cache written by the
|
|
161
|
+
// SessionStart hook via `peaks outer-cache write`. The file lives
|
|
162
|
+
// under `.peaks/_runtime/` (gitignored) so no .gitignore change is
|
|
163
|
+
// required. Any IO error or non-string `outerSessionId` field is
|
|
164
|
+
// treated as a cache miss — never throw.
|
|
165
|
+
if (projectRoot !== undefined) {
|
|
166
|
+
const cachePath = join(projectRoot, '.peaks', OUTER_SESSION_CACHE_FILE);
|
|
167
|
+
if (existsSync(cachePath)) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = readFileSync(cachePath, 'utf8');
|
|
170
|
+
const parsed = JSON.parse(raw);
|
|
171
|
+
if (parsed !== null &&
|
|
172
|
+
typeof parsed === 'object' &&
|
|
173
|
+
typeof parsed.outerSessionId === 'string' &&
|
|
174
|
+
(parsed.outerSessionId).length > 0) {
|
|
175
|
+
resolved = parsed.outerSessionId;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
179
|
+
// fall through — file missing / malformed JSON / IO error → undefined
|
|
180
|
+
}
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
|
-
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
163
|
-
// fall through — file missing / malformed JSON / IO error → undefined
|
|
164
|
-
}
|
|
165
183
|
}
|
|
166
184
|
}
|
|
167
|
-
|
|
185
|
+
// Record the resolved value on every non-throw call (env hit,
|
|
186
|
+
// cache hit, or undefined fallback). The 4th rotation guard reads
|
|
187
|
+
// this field to short-circuit same-process re-resolves.
|
|
188
|
+
lastResolvedOuter = { value: resolved, resolvedAt: Date.now() };
|
|
189
|
+
return resolved;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Test-only seam: reset the module-scoped `lastResolvedOuter` so
|
|
193
|
+
* individual tests start with a clean slate. NOT exported as part
|
|
194
|
+
* of the public API; marked TODO(g2) for the v2.14.0 grace window.
|
|
195
|
+
*/
|
|
196
|
+
export function _resetLastResolvedOuterForTest() {
|
|
197
|
+
lastResolvedOuter = null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Slice 2026-08-06-session-cacde8-A.5a: resolve the current callerId
|
|
201
|
+
* (if any) and look up the per-caller binding. Returns the bound
|
|
202
|
+
* sessionId + creation timestamp, or `null` when caller-id
|
|
203
|
+
* resolution fails (`PEAKS_CALLER_NOT_RESOLVED`) or no per-caller
|
|
204
|
+
* file exists. The per-caller file is the 4.0.8 source of truth;
|
|
205
|
+
* `ensureSession` consults it BEFORE `readSessionFile` so a binding
|
|
206
|
+
* written by a previous call from the same caller is preferred over
|
|
207
|
+
* a stale `session.json` (the legacy single-file binding).
|
|
208
|
+
*/
|
|
209
|
+
function resolveCallerBindingForEnsure(projectRoot) {
|
|
210
|
+
let projection;
|
|
211
|
+
try {
|
|
212
|
+
projection = resolveCallerProjection({ projectRoot, env: process.env });
|
|
213
|
+
}
|
|
214
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const binding = getCallerBinding(projectRoot, projection.callerId);
|
|
219
|
+
if (binding === null)
|
|
220
|
+
return null;
|
|
221
|
+
return {
|
|
222
|
+
sessionId: binding.peakSessionId,
|
|
223
|
+
createdAt: binding.createdAt,
|
|
224
|
+
callerId: projection.callerId
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
168
230
|
}
|
|
169
231
|
export async function ensureSession(projectRoot) {
|
|
232
|
+
// Slice 2026-08-06-session-cacde8-A.5a: 3-tier read order —
|
|
233
|
+
// (1) per-caller binding (caller-keyed, primary), (2) legacy
|
|
234
|
+
// `session.json`, (3) fresh-generate. The per-caller lookup is
|
|
235
|
+
// preferred when `resolveCallerProjection` succeeds AND the
|
|
236
|
+
// per-caller file exists. When `resolveCallerProjection` throws
|
|
237
|
+
// `PEAKS_CALLER_NOT_RESOLVED` we fall through to the session.json
|
|
238
|
+
// path; the legacy fallback preserves existing CLI behaviour for
|
|
239
|
+
// projects without an active IDE adapter.
|
|
240
|
+
const callerBinding = resolveCallerBindingForEnsure(projectRoot);
|
|
241
|
+
if (callerBinding !== null) {
|
|
242
|
+
// Already-bound path via caller-binding (primary). Stamp the
|
|
243
|
+
// current outer-session-id onto the bound session's meta (G3).
|
|
244
|
+
const outerSessionId = getCurrentOuterSessionId(projectRoot);
|
|
245
|
+
if (outerSessionId !== undefined) {
|
|
246
|
+
setSessionMeta(projectRoot, callerBinding.sessionId, { outerSessionId });
|
|
247
|
+
}
|
|
248
|
+
// Slice 2026-08-06-session-cacde8-A.5a: dual-write — also write
|
|
249
|
+
// the legacy `session.json` (denormalized cache) when missing,
|
|
250
|
+
// so legacy consumers (e.g. `getSessionId` callers without a
|
|
251
|
+
// resolved callerId) still find the binding. The per-caller
|
|
252
|
+
// file is the source of truth; `session.json` is regenerated
|
|
253
|
+
// here for back-compat.
|
|
254
|
+
if (readSessionFile(projectRoot) === null) {
|
|
255
|
+
writeSessionFile(projectRoot, {
|
|
256
|
+
sessionId: callerBinding.sessionId,
|
|
257
|
+
createdAt: callerBinding.createdAt,
|
|
258
|
+
projectRoot
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return callerBinding.sessionId;
|
|
262
|
+
}
|
|
170
263
|
const existing = readSessionFile(projectRoot);
|
|
171
264
|
if (existing) {
|
|
172
265
|
// Slice 2026-08-06-session-outer-cache (G3 / AC8-AC11): on every
|
|
@@ -210,6 +303,39 @@ export async function ensureSession(projectRoot) {
|
|
|
210
303
|
createdAt: now,
|
|
211
304
|
projectRoot
|
|
212
305
|
};
|
|
306
|
+
// Slice 2026-08-06-session-cacde8-A.5a: dual-write on fresh-generate.
|
|
307
|
+
// The per-caller file is the 4.0.8 source of truth; the legacy
|
|
308
|
+
// `session.json` is a denormalized cache for consumers that have
|
|
309
|
+
// not migrated to `getCallerBinding`. Per-caller file is written
|
|
310
|
+
// FIRST; on partial-write failure of either file, the next
|
|
311
|
+
// `ensureSession` will re-derive `session.json` from the
|
|
312
|
+
// per-caller file via the tier-1 read path above.
|
|
313
|
+
const newCallerId = (() => {
|
|
314
|
+
try {
|
|
315
|
+
return resolveCallerProjection({ projectRoot, env: process.env }).callerId;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
})();
|
|
321
|
+
if (newCallerId !== null) {
|
|
322
|
+
const payload = {
|
|
323
|
+
callerId: newCallerId,
|
|
324
|
+
peakSessionId: sessionId,
|
|
325
|
+
projectRoot,
|
|
326
|
+
createdAt: now,
|
|
327
|
+
lastActivityAt: now,
|
|
328
|
+
skill: 'peaks-code',
|
|
329
|
+
mode: 'unknown',
|
|
330
|
+
gate: 'startup'
|
|
331
|
+
};
|
|
332
|
+
try {
|
|
333
|
+
setCallerBinding(projectRoot, newCallerId, payload);
|
|
334
|
+
}
|
|
335
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
336
|
+
// best effort; session.json write below is the legacy fallback
|
|
337
|
+
}
|
|
338
|
+
}
|
|
213
339
|
writeSessionFile(projectRoot, info);
|
|
214
340
|
await initWorkspace({ projectRoot, sessionId });
|
|
215
341
|
// Initialize session metadata inside the session directory.
|
|
@@ -238,7 +364,7 @@ export async function ensureSession(projectRoot) {
|
|
|
238
364
|
* binding changes — and the rotation is surfaced in the return value
|
|
239
365
|
* so the CLI can include it in the JSON envelope.
|
|
240
366
|
*
|
|
241
|
-
* Rotation is suppressed in
|
|
367
|
+
* Rotation is suppressed in four cases (all false-positive guards):
|
|
242
368
|
*
|
|
243
369
|
* 1. The current outer session id is undefined (no env var set) —
|
|
244
370
|
* there is no signal to compare against, defaulting to "do not
|
|
@@ -249,6 +375,16 @@ export async function ensureSession(projectRoot) {
|
|
|
249
375
|
* 3. The bound session's recorded outer session id matches the
|
|
250
376
|
* current one (reconnect within the same Claude session) — this
|
|
251
377
|
* is the common case, not a swap.
|
|
378
|
+
* 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
|
|
379
|
+
* `lastResolvedOuter.value` was already set to `currentOuterSessionId`
|
|
380
|
+
* in this process and `boundOuter` equals that same value. Long-
|
|
381
|
+
* running presence-lease writers that re-resolve within a single
|
|
382
|
+
* process must not rotate on every CLI heartbeat. The module-scoped
|
|
383
|
+
* state is per-process; each fresh CLI invocation starts with
|
|
384
|
+
* `lastResolvedOuter === null`, so this guard cannot leak across
|
|
385
|
+
* processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
|
|
386
|
+
* is a defined string (an undefined last-resolved value still
|
|
387
|
+
* falls through to the legacy comparison path).
|
|
252
388
|
*
|
|
253
389
|
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
254
390
|
* check is short-circuited and the binding is preserved (opt-out for
|
|
@@ -277,6 +413,26 @@ export async function ensureSessionWithRotation(projectRoot, options) {
|
|
|
277
413
|
if (boundSessionId !== null && currentOuterSessionId !== undefined) {
|
|
278
414
|
const boundMeta = getSessionMeta(projectRoot, boundSessionId);
|
|
279
415
|
const boundOuter = boundMeta?.outerSessionId;
|
|
416
|
+
// Slice 2026-08-06-session-cacde8-A.3: 4th guard — same-process
|
|
417
|
+
// re-resolve. When `lastResolvedOuter` was already set to the
|
|
418
|
+
// current outer AND the bound session's recorded outer equals
|
|
419
|
+
// the same value, the rotation decision was already evaluated
|
|
420
|
+
// in this process. Without this short-circuit, a long-running
|
|
421
|
+
// presence-lease writer would rotate on every heartbeat within
|
|
422
|
+
// the same process. Skipping only fires when `lastResolvedOuter`
|
|
423
|
+
// is a defined string (undefined last-resolved values fall
|
|
424
|
+
// through to the legacy comparison path so the first-ever
|
|
425
|
+
// resolve still works).
|
|
426
|
+
if (lastResolvedOuter?.value !== undefined &&
|
|
427
|
+
lastResolvedOuter.value === currentOuterSessionId &&
|
|
428
|
+
lastResolvedOuter.value === boundOuter) {
|
|
429
|
+
const sessionId = await ensureSession(projectRoot);
|
|
430
|
+
return {
|
|
431
|
+
sessionId,
|
|
432
|
+
previousSessionId: null,
|
|
433
|
+
rotationReason: null
|
|
434
|
+
};
|
|
435
|
+
}
|
|
280
436
|
if (typeof boundOuter === 'string' &&
|
|
281
437
|
boundOuter.length > 0 &&
|
|
282
438
|
boundOuter !== currentOuterSessionId &&
|
|
@@ -11,6 +11,8 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
|
|
13
13
|
import { ensureSession } from './session-binding-bridge.js';
|
|
14
|
+
import { getCallerBinding } from './caller-binding-service.js';
|
|
15
|
+
import { resolveCallerProjection } from './resolve-caller-id.js';
|
|
14
16
|
// As of slice 2026-06-05-peaks-runtime-layer the project-level session
|
|
15
17
|
// binding lives under `.peaks/_runtime/session.json`. The legacy
|
|
16
18
|
// `.peaks/.session.json` path is preserved as a read-only fallback for one
|
|
@@ -398,6 +400,18 @@ export { ensureSession, ensureSessionWithRotation } from './session-binding-brid
|
|
|
398
400
|
* @returns Session ID or null
|
|
399
401
|
*/
|
|
400
402
|
export function getSessionId(projectRoot) {
|
|
403
|
+
// Slice 2026-08-06-session-cacde8-A.5a: caller-binding becomes
|
|
404
|
+
// primary source. Read order is (1) `getCallerBinding` if
|
|
405
|
+
// `resolveCallerProjection` succeeds, (2) `readSessionFile`
|
|
406
|
+
// (the legacy session.json), (3) null (no binding). The legacy
|
|
407
|
+
// session.json is preserved as a fallback so a project without
|
|
408
|
+
// a caller-id resolution still resolves its binding (e.g. CLI
|
|
409
|
+
// run from a stock shell with no IDE adapter). `PEAKS_CALLER_NOT_RESOLVED`
|
|
410
|
+
// falls through to the session.json path; no caller-facing exit
|
|
411
|
+
// change.
|
|
412
|
+
const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
|
|
413
|
+
if (fromCallerBinding !== null)
|
|
414
|
+
return fromCallerBinding;
|
|
401
415
|
const info = readSessionFile(projectRoot);
|
|
402
416
|
return info?.sessionId ?? null;
|
|
403
417
|
}
|
|
@@ -429,9 +443,32 @@ export function getSessionId(projectRoot) {
|
|
|
429
443
|
* so this variant is opt-in.
|
|
430
444
|
*/
|
|
431
445
|
export function getSessionIdCanonical(projectRoot) {
|
|
446
|
+
// Slice 2026-08-06-session-cacde8-A.5a: same caller-binding primary
|
|
447
|
+
// lookup as `getSessionId`; fall back to the canonical-fallback
|
|
448
|
+
// `readSessionFileCanonical` if caller-binding is absent / unresolved.
|
|
449
|
+
const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
|
|
450
|
+
if (fromCallerBinding !== null)
|
|
451
|
+
return fromCallerBinding;
|
|
432
452
|
const info = readSessionFileCanonical(projectRoot);
|
|
433
453
|
return info?.sessionId ?? null;
|
|
434
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Internal helper: read the caller-binding primary source.
|
|
457
|
+
* Returns `null` when caller-id resolution fails
|
|
458
|
+
* (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file exists,
|
|
459
|
+
* or when the file is malformed. NOT exported; the public surface
|
|
460
|
+
* is still `getSessionId` / `getSessionIdCanonical`.
|
|
461
|
+
*/
|
|
462
|
+
function getSessionIdFromCallerBinding(projectRoot) {
|
|
463
|
+
try {
|
|
464
|
+
const projection = resolveCallerProjection({ projectRoot, env: process.env });
|
|
465
|
+
const binding = getCallerBinding(projectRoot, projection.callerId);
|
|
466
|
+
return binding?.peakSessionId ?? null;
|
|
467
|
+
}
|
|
468
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
435
472
|
/**
|
|
436
473
|
* Get the absolute path to the current session directory.
|
|
437
474
|
* Creates the session if it doesn't exist.
|
|
@@ -319,7 +319,19 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
|
|
|
319
319
|
import('./presence-lease-service.js'),
|
|
320
320
|
]);
|
|
321
321
|
const projection = resolveCallerProjection({ projectRoot, env: process.env });
|
|
322
|
-
|
|
322
|
+
// Slice 2026-08-06-session-cacde8-A.4: derive the legacy compat
|
|
323
|
+
// shim's workflowId from `projection.callerId` (caller-keyed,
|
|
324
|
+
// matching the 4.0.8 caller-binding direction) instead of
|
|
325
|
+
// `sessionId` (sid-keyed, pre-4.0.8). `slice(0, 189)` caps the
|
|
326
|
+
// callerId to 189 chars so the resulting workflowId
|
|
327
|
+
// (`wf-` + callerId + `-compat`) stays under the 200-char
|
|
328
|
+
// `WORKFLOW_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/` cap. The
|
|
329
|
+
// truncation is loss-free for regex purposes — callerId is
|
|
330
|
+
// already regex-conformant before the slice. Existing 4.0.14
|
|
331
|
+
// leases with `wf-<sid>-compat` become orphans and are GC'd
|
|
332
|
+
// by the 24h30m stale-started GC at
|
|
333
|
+
// presence-lease-service.ts:42.
|
|
334
|
+
const workflowId = `wf-${projection.callerId.slice(0, 189)}-compat`;
|
|
323
335
|
const result = leaseMod.setPresenceLease({
|
|
324
336
|
projectRoot,
|
|
325
337
|
sessionId,
|