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.
Files changed (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/config/eslint/.peaks-rules.cjs +123 -0
  3. package/dist/cli/commands/container-commands.js +2 -1
  4. package/dist/cli/commands/core/skill-command.js +32 -5
  5. package/dist/cli/commands/openspec-commands.js +2 -1
  6. package/dist/reporters/bdd-reporter.d.ts +36 -0
  7. package/dist/reporters/bdd-reporter.js +159 -0
  8. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
  9. package/dist/services/audit/enforcers/active-skill-resolver.js +53 -39
  10. package/dist/services/container/container-lease.js +2 -1
  11. package/dist/services/impact/impact-scan-service.js +4 -3
  12. package/dist/services/migrate-skill-name/migrate.js +2 -1
  13. package/dist/services/openspec/artifact-boundary.js +3 -2
  14. package/dist/services/openspec/coverage-evidence-reader.js +9 -8
  15. package/dist/services/prd/handoff-auto-regen.js +2 -1
  16. package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
  17. package/dist/services/qa/bdd-test-style-verifier.js +268 -0
  18. package/dist/services/scan/type-sanity-service.js +2 -1
  19. package/dist/services/session/session-binding-bridge.js +17 -19
  20. package/dist/services/session/session-manager.js +36 -12
  21. package/dist/services/skills/presence-lease-service.js +1 -0
  22. package/dist/services/skills/skill-statusline-renderer.js +29 -32
  23. package/dist/services/skills/skill-statusline-service.d.ts +6 -0
  24. package/dist/services/skills/skill-statusline-service.js +107 -7
  25. package/dist/services/vm/vm-lease.js +2 -1
  26. package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
  27. package/dist/services/workspace/workspace-service.js +2 -1
  28. package/dist/services/worktree/worktree-lease.js +2 -1
  29. package/dist/shared/path-safety.js +3 -5
  30. package/dist/shared/path-utils.d.ts +48 -0
  31. package/dist/shared/path-utils.js +65 -1
  32. package/docs/test-style-contract.md +135 -0
  33. package/package.json +5 -3
  34. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
  35. 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.replace(/\\/g, '/');
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, realpathSync, writeFileSync } from 'node:fs';
22
- import { dirname, join, resolve } from 'node:path';
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 && data.projectRoot === projectRoot) {
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
- storedRaw !== null &&
85
- resolveStoredAgainstCaller(storedRaw, projectRoot) === resolveStoredAgainstCaller(projectRoot, projectRoot)) {
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
- writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
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
- * Strict equality on `data.projectRoot === projectRoot` is
93
- * preserved here on purpose: many other modules depend on
94
- * the strict-equality semantics to test the "no session bound"
95
- * code path. Changing the read semantics here would cascade
96
- * into many test failures — out of scope for the progress
97
- * rebind fix.
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
- * The progress subcommands (which are the surface that
100
- * actually breaks on the rebind bug) use
101
- * `getSessionIdCanonical` instead, which does the
102
- * canonicalize-on-read resolution the bug fix needs.
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
- writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
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 = 2_400;
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
- // Bee-tier skills surface both layers: the active bee name plus
230
- // the full parent orchestrator name. Mode is shown for every
231
- // active skill (not just peaks-code) so the line carries the same
232
- // mode taxonomy for any bee in flight.
233
- if (beeParent !== undefined) {
234
- return `${dot} ${brandRun(skill, noColor, capability)} ${brandRun(`↑${beeParent}`, noColor, capability)}${modeToken}`;
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 2 s round trip. The band's
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 = 2_000;
434
- const MARQUEE_BAND_WIDTH = 5;
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;