peaks-loop 4.0.9 → 4.0.11
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 +53 -0
- package/config/eslint/.peaks-rules.cjs +123 -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/reporters/bdd-reporter.d.ts +36 -0
- package/dist/reporters/bdd-reporter.js +159 -0
- package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +53 -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/qa/bdd-test-style-verifier.d.ts +88 -0
- package/dist/services/qa/bdd-test-style-verifier.js +268 -0
- 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/presence-lease-service.js +1 -0
- 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 +107 -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/docs/test-style-contract.md +135 -0
- package/package.json +5 -3
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +21 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/services/qa/bdd-test-style-verifier.ts
|
|
3
|
+
*
|
|
4
|
+
* rid-2026-08-05-bdd-test-style Slice B — peaks-qa verification-time
|
|
5
|
+
* BDD test-style verifier. This is the read-only, post-edit companion
|
|
6
|
+
* to the `scripts/migrate-to-bdd.mjs` AST migrator shipped in Slice A.
|
|
7
|
+
*
|
|
8
|
+
* Purpose:
|
|
9
|
+
* When peaks-qa runs its verification gate, it picks up the git diff
|
|
10
|
+
* for the slice and asks this module whether the new / modified test
|
|
11
|
+
* files comply with the BDD given-when-then style. The verdict is
|
|
12
|
+
* surfaced as either `ok` (and the slice can advance) or one of two
|
|
13
|
+
* structured failure reasons (`missing-given-when-then` or
|
|
14
|
+
* `description-no-should-when`) that the caller turns into a
|
|
15
|
+
* `qa-handoff` rejection back to peaks-rd.
|
|
16
|
+
*
|
|
17
|
+
* Why a real AST and not a regex:
|
|
18
|
+
* The Slice A migrator established the convention: test files have
|
|
19
|
+
* multi-line `it(...)` calls, nested arrow bodies, and string
|
|
20
|
+
* literals that often contain words like "when" inside the assertion
|
|
21
|
+
* message (not in the description). A regex pass on the raw source
|
|
22
|
+
* would false-positive on string internals. The TypeScript Compiler
|
|
23
|
+
* API (already a dev dep via vitest) lets us:
|
|
24
|
+
* 1. Inspect the first `StringLiteral` argument of an `it` /
|
|
25
|
+
* `test` / `describe` call without scanning comments or
|
|
26
|
+
* string content inside the body.
|
|
27
|
+
* 2. Walk only the leading-comment ranges that sit before the
|
|
28
|
+
* first statement of the callback block, so a `// when:`
|
|
29
|
+
* inside an `expect(actual).toEqual('when X happens')` is
|
|
30
|
+
* correctly ignored.
|
|
31
|
+
*
|
|
32
|
+
* No new dependencies. The verifier is intentionally synchronous and
|
|
33
|
+
* pure (input source + path list -> verdict) so peaks-qa can call it
|
|
34
|
+
* from a deterministic verification step without subprocess overhead.
|
|
35
|
+
*
|
|
36
|
+
* Anti-fake-green (CLI silent-catch rule):
|
|
37
|
+
* This module throws on parse failure. It does NOT swallow parse
|
|
38
|
+
* errors and return `{ ok: true }` — that would silently green-light
|
|
39
|
+
* malformed test files. A parse error is a structural problem; the
|
|
40
|
+
* caller must surface it.
|
|
41
|
+
*/
|
|
42
|
+
import { readFileSync } from 'node:fs';
|
|
43
|
+
import { resolve } from 'node:path';
|
|
44
|
+
import ts from 'typescript';
|
|
45
|
+
/** Test runners whose first string-arg is the test description. */
|
|
46
|
+
const TEST_NAMES = new Set(['it', 'test']);
|
|
47
|
+
/**
|
|
48
|
+
* Verify that every `it(...)` / `test(...)` call in the given test
|
|
49
|
+
* files follows the BDD given-when-then contract.
|
|
50
|
+
*
|
|
51
|
+
* Contract:
|
|
52
|
+
* 1. The first `StringLiteral` argument of every `it` / `test` call
|
|
53
|
+
* MUST match `/(\bwhen\b|\bshould\b)/` (word-boundary anchored,
|
|
54
|
+
* case-insensitive). A regex on the raw description is correct
|
|
55
|
+
* here because the description itself is a literal — there is
|
|
56
|
+
* no nested template literal to misread.
|
|
57
|
+
* 2. The callback body (the second argument when it is an arrow /
|
|
58
|
+
* function expression with a block) MUST have a `// given:`,
|
|
59
|
+
* `// when:`, `// then:` triple at the top, in that order,
|
|
60
|
+
* within the first 3 leading-comment ranges before the first
|
|
61
|
+
* statement. The `// arrange:` / `// act:` / `// assert:` AAA
|
|
62
|
+
* legacy is NOT accepted — the contract is given-when-then
|
|
63
|
+
* only.
|
|
64
|
+
*
|
|
65
|
+
* Returns the FIRST failure encountered (file order, then
|
|
66
|
+
* top-to-bottom line order). A structured `BddStyleFail` is what the
|
|
67
|
+
* caller maps to `qa-handoff` rejection.
|
|
68
|
+
*/
|
|
69
|
+
export function verifyBddStyle(input) {
|
|
70
|
+
let scanned = 0;
|
|
71
|
+
for (const rel of input.testFiles) {
|
|
72
|
+
const absPath = resolve(input.projectRoot, rel);
|
|
73
|
+
const source = readFileSync(absPath, 'utf8');
|
|
74
|
+
const sourceFile = ts.createSourceFile(rel, source, ts.ScriptTarget.ESNext,
|
|
75
|
+
/* setParentNodes */ true, ts.ScriptKind.TS);
|
|
76
|
+
let earliestFail = null;
|
|
77
|
+
const recordFail = (fail) => {
|
|
78
|
+
if (earliestFail === null) {
|
|
79
|
+
earliestFail = fail;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const { line: existingLine } = earliestFail;
|
|
83
|
+
if (fail.line < existingLine)
|
|
84
|
+
earliestFail = fail;
|
|
85
|
+
};
|
|
86
|
+
const visit = (node) => {
|
|
87
|
+
if (earliestFail !== null)
|
|
88
|
+
return;
|
|
89
|
+
if (ts.isCallExpression(node)) {
|
|
90
|
+
const callee = node.expression;
|
|
91
|
+
if (ts.isIdentifier(callee) && TEST_NAMES.has(callee.text)) {
|
|
92
|
+
scanned += 1;
|
|
93
|
+
const descCheck = checkDescription(node, sourceFile, rel);
|
|
94
|
+
if (descCheck !== null) {
|
|
95
|
+
recordFail(descCheck);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const bodyCheck = checkBody(node, sourceFile, rel);
|
|
99
|
+
if (bodyCheck !== null) {
|
|
100
|
+
recordFail(bodyCheck);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
ts.forEachChild(node, visit);
|
|
106
|
+
};
|
|
107
|
+
visit(sourceFile);
|
|
108
|
+
if (earliestFail !== null)
|
|
109
|
+
return earliestFail;
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, scanned };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Inspect the first string-literal argument of an `it` / `test` call.
|
|
115
|
+
*
|
|
116
|
+
* - If the first argument is not a string literal, treat it as a
|
|
117
|
+
* failure (the BDD contract requires a literal description).
|
|
118
|
+
* - If the literal text does not contain "when" or "should" as a
|
|
119
|
+
* whole word, return a `description-no-should-when` failure.
|
|
120
|
+
*/
|
|
121
|
+
function checkDescription(call, sourceFile, relPath) {
|
|
122
|
+
const firstArg = call.arguments[0];
|
|
123
|
+
if (firstArg === undefined || !ts.isStringLiteralLike(firstArg)) {
|
|
124
|
+
const pos = call.getStart(sourceFile);
|
|
125
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: 'description-no-should-when',
|
|
129
|
+
file: relPath,
|
|
130
|
+
line: line + 1,
|
|
131
|
+
description: '<non-literal first argument>',
|
|
132
|
+
expected: 'first argument must be a string literal containing "when" or "should"',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const description = firstArg.text;
|
|
136
|
+
if (!hasWhenOrShould(description)) {
|
|
137
|
+
const pos = firstArg.getStart(sourceFile);
|
|
138
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
reason: 'description-no-should-when',
|
|
142
|
+
file: relPath,
|
|
143
|
+
line: line + 1,
|
|
144
|
+
description,
|
|
145
|
+
expected: 'description must contain the word "when" or "should" (BDD style)',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Inspect the callback body of an `it` / `test` call for the
|
|
152
|
+
* `// given:` / `// when:` / `// then:` triple.
|
|
153
|
+
*
|
|
154
|
+
* Rules (Slice A migrator + design §4.B):
|
|
155
|
+
* - The second argument must be an arrow / function expression
|
|
156
|
+
* with a block body. If it is missing or not a block (e.g. an
|
|
157
|
+
* expression-body arrow `it('x', () => expect(y).toBe(z))`),
|
|
158
|
+
* we still need the comments — but expression-body arrows
|
|
159
|
+
* cannot host them. In that case we fall back to inspecting
|
|
160
|
+
* the leading comments before the entire call expression,
|
|
161
|
+
* which matches the Slice A migrator's `isAlreadyMigrated`
|
|
162
|
+
* check shape.
|
|
163
|
+
* - The three comments must be the FIRST THREE leading-comment
|
|
164
|
+
* ranges before the relevant body / first-statement anchor.
|
|
165
|
+
* - The order must be `given` → `when` → `then`. A re-ordered
|
|
166
|
+
* triple is rejected.
|
|
167
|
+
*/
|
|
168
|
+
function checkBody(call, sourceFile, relPath) {
|
|
169
|
+
const body = getCallbackBlock(call);
|
|
170
|
+
if (body !== null) {
|
|
171
|
+
return checkBlockLeadingComments(body, sourceFile, relPath);
|
|
172
|
+
}
|
|
173
|
+
// Expression-body arrow or non-block callback: comments cannot
|
|
174
|
+
// live inside the body. The Slice A migrator only inserts the
|
|
175
|
+
// triple on block bodies, so an expression-body form is by
|
|
176
|
+
// definition non-BDD and must fail. This keeps the contract
|
|
177
|
+
// symmetric with the migrator.
|
|
178
|
+
const pos = call.getStart(sourceFile);
|
|
179
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: 'missing-given-when-then',
|
|
183
|
+
file: relPath,
|
|
184
|
+
line: line + 1,
|
|
185
|
+
expected: 'block-body callback with // given: / // when: / // then: comments at the top',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function getCallbackBlock(call) {
|
|
189
|
+
const callback = call.arguments[1];
|
|
190
|
+
if (callback === undefined)
|
|
191
|
+
return null;
|
|
192
|
+
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))
|
|
193
|
+
return null;
|
|
194
|
+
if (!callback.body || !ts.isBlock(callback.body))
|
|
195
|
+
return null;
|
|
196
|
+
return callback.body;
|
|
197
|
+
}
|
|
198
|
+
function checkBlockLeadingComments(block, sourceFile, relPath) {
|
|
199
|
+
// TypeScript's `getLeadingCommentRanges` API is unreliable for
|
|
200
|
+
// comment-only blocks: with `setParentNodes: true`, an empty
|
|
201
|
+
// block (no statements, only comments) has no anchor to attach
|
|
202
|
+
// the comments to, so the API returns zero ranges. To get a
|
|
203
|
+
// deterministic answer, we scan the block's text directly and
|
|
204
|
+
// pick the first three non-empty lines.
|
|
205
|
+
//
|
|
206
|
+
// The block's text spans `{` ... `}`. We extract the body,
|
|
207
|
+
// split on lines, and check the first three non-empty lines for
|
|
208
|
+
// the BDD triple. This is AST-driven (we use the block's source
|
|
209
|
+
// range from the SourceFile, not a global regex) and survives
|
|
210
|
+
// both empty-body and populated-body cases.
|
|
211
|
+
const blockStart = block.getStart(sourceFile) + 1; // skip `{`
|
|
212
|
+
const blockEnd = block.end - 1; // skip `}`
|
|
213
|
+
const body = sourceFile.text.slice(blockStart, blockEnd);
|
|
214
|
+
const lines = body.split(/\r?\n/);
|
|
215
|
+
const nonEmpty = [];
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
if (line.trim().length === 0)
|
|
218
|
+
continue;
|
|
219
|
+
nonEmpty.push(line);
|
|
220
|
+
if (nonEmpty.length === 3)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (nonEmpty.length < 3 || !matchesBddTriple(nonEmpty)) {
|
|
224
|
+
return makeMissingCommentFailure(block, sourceFile, relPath);
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
function makeMissingCommentFailure(block, sourceFile, relPath) {
|
|
229
|
+
// Report the line of the opening `{` + 1 — the line that should
|
|
230
|
+
// contain the first comment of the BDD triple. This gives the
|
|
231
|
+
// caller a stable pointer even when the block is empty.
|
|
232
|
+
const pos = block.getStart(sourceFile) + 1;
|
|
233
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
reason: 'missing-given-when-then',
|
|
237
|
+
file: relPath,
|
|
238
|
+
line: line + 1,
|
|
239
|
+
expected: '// given: / // when: / // then: triple at the top of the block body',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Match the three leading comments against the BDD triple. Each
|
|
244
|
+
* entry must be a `// <keyword>:` line (with optional trailing
|
|
245
|
+
* whitespace); the keywords must appear in `given`, `when`, `then`
|
|
246
|
+
* order, case-insensitive.
|
|
247
|
+
*/
|
|
248
|
+
function matchesBddTriple(triple) {
|
|
249
|
+
if (triple.length !== 3)
|
|
250
|
+
return false;
|
|
251
|
+
// Each entry must be a `// <keyword>:` line, optionally followed
|
|
252
|
+
// by descriptive text. The Slice A migrator's `buildCommentBlock`
|
|
253
|
+
// produces `// given: the test setup` / `// when: the function
|
|
254
|
+
// under test is invoked` / `// then: the result matches the
|
|
255
|
+
// expectation` — the `when` line uses two spaces after the colon
|
|
256
|
+
// for visual alignment with `given:` and `then:`, so the regex
|
|
257
|
+
// is intentionally permissive about trailing text.
|
|
258
|
+
const patterns = [
|
|
259
|
+
/^\s*\/\/\s*given\s*:/i,
|
|
260
|
+
/^\s*\/\/\s*when\s*:/i,
|
|
261
|
+
/^\s*\/\/\s*then\s*:/i,
|
|
262
|
+
];
|
|
263
|
+
return patterns.every((pat, i) => pat.test(triple[i] ?? ''));
|
|
264
|
+
}
|
|
265
|
+
/** True when `text` contains `when` or `should` as a whole word. */
|
|
266
|
+
function hasWhenOrShould(text) {
|
|
267
|
+
return /(\bwhen\b|\bshould\b)/i.test(text);
|
|
268
|
+
}
|
|
@@ -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
|
|
@@ -142,6 +142,7 @@ export function setPresenceLease(input) {
|
|
|
142
142
|
graphRef,
|
|
143
143
|
skill: input.skill,
|
|
144
144
|
...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
|
|
145
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
145
146
|
depth: input.depth ?? 0,
|
|
146
147
|
startedAt: now,
|
|
147
148
|
lastHeartbeat: now,
|
|
@@ -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;
|