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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.11 — 2026-08-05 (BDD test-style + statusline bugs)
4
+
5
+ **BDD given-when-then test style** (rid-2026-08-05-bdd-test-style, 5 slice / 19 commit):
6
+ - `scripts/migrate-to-bdd.mjs` — TS Compiler API-based AST migrator that rewrites every `it()` / `test()` / `describe()` to the given-when-then contract (it description with `when X` or `should Y` + 3-line `// given:` / `// when:` / `// then:` body comment). Idempotent.
7
+ - `src/services/qa/bdd-test-style-verifier.ts` — peaks-qa verification-time verifier that scans `git diff HEAD~1 -- '*.test.ts'` and rejects non-BDD slices (LLM-only enforcement, since callerId from `process.env.CLAUDE_CODE_SESSION_ID` cannot distinguish LLM vs human in Claude Code).
8
+ - `src/reporters/bdd-reporter.ts` — vitest custom reporter (flag-enabled via `--reporter ./src/reporters/bdd-reporter.ts`) emitting `Feature: <file>` / `Scenario: <describe>` / `Given|When|Then` document view.
9
+ - `skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md` + `peaks-qa/references/qa-sub-agent-dispatch.md` — `## BDD Test Style Contract` / `## BDD Test Style Verification` soft-constraint sections added.
10
+ - `docs/test-style-contract.md` — LLM test-style guide included in npm `files` array (downstream opt-in).
11
+ - 26 test files migrated across 11 commits (one per top-level directory); 12 files already BDD-form (idempotent migrator skipped silently); 49 unit-test files total now in BDD shape.
12
+ - 49/49 unit tests behaviour-preserved (488 passed / 25 skipped / 0 introduced failures).
13
+
14
+ **Statusline bug fixes** (3 rid this release):
15
+ - `skill-statusline-service.ts` `readActiveLeaf`: stale `queued` dispatch entries no longer pollute statusline as in-flight leaves. `terminalStatuses` set extended.
16
+ - `presence-lease-service.ts` `setPresenceLease`: lease object now persists `mode` (was being silently dropped — regression from 4.0.8 Presence Lease Graph introduction). `[full-auto]` / `[assisted]` / `[swarm]` / `[strict]` tags now render.
17
+ - `audit/enforcers/active-skill-resolver.ts` legacy fall-back: per-caller `active-skill-*.json` legacy walk now reads and propagates `mode` (was hard-coded `mode: null`).
18
+
19
+ **Build-chain repair** (silences `npx tsc -p tsconfig.build.json` regression):
20
+ - `src/reporters/bdd-reporter.ts` no longer imports the un-exported `TestModule` / `TestCase` from `vitest/reporters`. Local minimal interfaces (`BddTestModuleLike` / `BddTestCaseLike`) match the runtime shape vitest passes to reporter hooks.
21
+ - Build now succeeds end-to-end; the `dist/` artifacts (which `bin/peaks.js` actually loads) reflect the source-level fixes.
22
+
23
+ **Cleanup tail** (5 rid carried over from b1 sweep):
24
+ - 63 request artifacts re-staged to terminal state (handed-off / verdict-issued / complete / sc-handoff → done).
25
+ - 8 OpenSpec proposal drift detected and routed through `peaks request transition`.
26
+ - One envelope-test-output log dropped from project root (was orphan inside `.gitignore:5 *.log` but never deleted).
27
+
28
+ **Lockstep bump.** peaks-loop-shared `0.0.40 → 0.0.41` (CLI_VERSION re-stamped to 4.0.11).
29
+
30
+ ## 4.0.10 — 2026-08-04 (path-canonicalize + statusline-read-isolation)
31
+
32
+ **Windows statusline fixed.** `peaks-loop@4.0.9` always rendered `peaks empty` on Windows Git Bash. Root cause: the session-binding reader used strict `===` to compare `projectRoot`; the binding had been written with backslashes (`C:\Users\...`) but `peaks skill presence:set --project C:/Users/...` arrived with forward slashes, and Node treats them as distinct strings. The 4.0.8 fail-closed `PEAKS_SESSION_NOT_BOUND` gate then blocked the presence marker write, and the statusline never had a real skill to display.
33
+
34
+ **Cross-platform path canonicalization** (5 rid: `5ae2fa6d` / `e8b467d8` / `4e22ce39` / `033df6f6` / `8da617de`):
35
+ - `src/services/session/session-manager.ts` + `src/services/session/session-binding-bridge.ts` — `readSessionFile` / `writeSessionFile` use the canonical `projectRootsMatch` (stableRealPath + normalizePath + isWindows case-fold).
36
+ - `src/shared/path-utils.ts` — `projectRootsMatch` lifted from session-manager to be the single source of truth for cross-platform path comparison.
37
+ - 17 out-of-scope `replace(/\\/g, '/')` sites consolidated to `path-utils.normalizePath`. The remaining 1 site (`karpathy-service.ts:222`) is a reverse escape (`\\` → `\\\\`) and is intentionally out of scope.
38
+ - Hard rule entered `.peaks/standards/common/coding-style.md`: no strict `===` on filesystem paths, no hand-rolled `realpathSync` in non-`path-utils` modules, no `path.replace(/\\/g, '/')` in new code. Sediment at `.peaks/memory/2026-08-04-cross-platform-path-utility-rule.md`.
39
+
40
+ **Statusline read-side isolation** (1 rid: `8da617de`):
41
+ - `peaks statusline` now reads from `active-skill-resolver.resolveActiveSkillForCaller` (canonical lease walk) instead of the project-level single file. callerId extracted from `stdin.caller_id` → `CLAUDE_CODE_SESSION_ID` env → null fallback.
42
+ - Multi-session isolation: `peaks skill presence:set` in session A no longer leaks into session B's statusline.
43
+ - Active leaf display: statusline now shows `${leaf} (+N-1) | peaks-code [mode]` when sub-agents are in flight (e.g. `peaks-rd | peaks-code [full-auto]`). The 14→1 bee skill → `peaks-code` mapping in the renderer is removed.
44
+ - **Bug fix discovered and closed during rid-005**: `active-skill-resolver.ts` was reading from the session directory root expecting `presence-*.json` files, but `presence-lease-service.ts` actually writes under `leases/` subdir. Option A fix: resolver now reads from the canonical `leases/` subdir via `listPresenceLeases`.
45
+
46
+ **Shorter animation periods** (2 rid: `52a968f7` / `532bd1b1`):
47
+ - `MARQUEE_PERIOD_MS` 2000 → 800 → 400 (statusline scan band round trip)
48
+ - `BREATHING_PERIOD_MS` 2400 → 1200 → 600 (breathing glyph rotation)
49
+ - `MARQUEE_BAND_WIDTH` 5 → 3 → 2 (highlight band width)
50
+ - At 400ms / 600ms periods, 0.5-1s turn intervals produce visibly different breathing glyph + band position on every statusline render.
51
+
52
+ **Tests.** 88/88 statusline vitest green; 47 path-canonicalize test cases green across 4 test files. ESM repros validate 0.5-1.5s turn-interval phase difference at the production level.
53
+
54
+ **Lockstep bump.** peaks-loop-shared `0.0.39 → 0.0.40` (CLI_VERSION re-stamped to 4.0.10).
55
+
3
56
  ## 4.0.9 — 2026-08-04 (statusline-marquee)
4
57
 
5
58
  **statusline: brand purple + scan-band marquee + NO_COLOR support.**
@@ -0,0 +1,123 @@
1
+ /**
2
+ * peaks-loop ESLint rules bundle (npm-package exports)
3
+ *
4
+ * rid-2026-08-05-jsts-lint-bundle — LLM auto-fix loop trigger.
5
+ *
6
+ * This file is a JSON-shape glue config. It does NOT define custom
7
+ * rules. It composes upstream packages only:
8
+ *
9
+ * - eslint:recommended
10
+ * - plugin:@typescript-eslint/recommended-type-checked
11
+ * - plugin:import/recommended
12
+ * - plugin:import/typescript
13
+ *
14
+ * Framework-specific rules (eslint-plugin-react, eslint-plugin-vue,
15
+ * eslint-plugin-svelte, eslint-plugin-nestjs, etc.) are LAYER 3 and
16
+ * loaded dynamically by `peaks code lint` via `npx --package <pkg>
17
+ * -- eslint`. They are NOT installed in this package's devDependencies
18
+ * (sediment §二 G-lint-1 turn-5 red line).
19
+ *
20
+ * --fix / --write / prettier are FORBIDDEN at the peaks code lint
21
+ * wrapper entry; the wrapper is a read-only verifier, not a formatter
22
+ * (sediment §二 G-lint-2). The thresholds below are intentionally
23
+ * permissive (warn, not error) so peaks-loop 4.0.10 baseline can adopt
24
+ * the bundle without auto-failing.
25
+ */
26
+ 'use strict';
27
+
28
+ /** @type {import('eslint').Linter.Config} */
29
+ module.exports = {
30
+ root: false,
31
+ parser: '@typescript-eslint/parser',
32
+ parserOptions: {
33
+ ecmaVersion: 2022,
34
+ sourceType: 'module',
35
+ project: ['./tsconfig.json', './tsconfig.build.json'],
36
+ tsconfigRootDir: __dirname + '/..'
37
+ },
38
+ env: {
39
+ node: true,
40
+ es2022: true
41
+ },
42
+ plugins: ['@typescript-eslint', 'import'],
43
+ extends: [
44
+ 'eslint:recommended',
45
+ 'plugin:@typescript-eslint/recommended-type-checked',
46
+ 'plugin:import/recommended',
47
+ 'plugin:import/typescript'
48
+ ],
49
+ settings: {
50
+ 'import/resolver': {
51
+ typescript: {
52
+ alwaysTryTypes: true,
53
+ project: ['./tsconfig.json', './tsconfig.build.json']
54
+ },
55
+ node: {
56
+ extensions: ['.js', '.ts', '.tsx', '.jsx']
57
+ }
58
+ }
59
+ },
60
+ ignorePatterns: [
61
+ 'node_modules/',
62
+ 'dist/',
63
+ 'coverage/',
64
+ 'output-styles/',
65
+ 'skills/',
66
+ 'agents/',
67
+ 'bin/',
68
+ 'scratch/',
69
+ 'examples/'
70
+ ],
71
+ rules: {
72
+ // L1 (eslint built-in) — always on, no plugin package required.
73
+ complexity: ['warn', { max: 10 }],
74
+ 'max-lines-per-function': [
75
+ 'warn',
76
+ { max: 50, skipComments: true, skipBlankLines: true }
77
+ ],
78
+ 'max-params': ['warn', { max: 4 }],
79
+ 'no-magic-numbers': [
80
+ 'warn',
81
+ { ignore: [0, 1, -1, 100, 1000] }
82
+ ],
83
+ 'no-explicit-any': 'warn',
84
+ 'prefer-const': 'warn',
85
+ 'no-var': 'error',
86
+ eqeqeq: ['warn', 'always', { null: 'ignore' }],
87
+
88
+ // L2 (@typescript-eslint) — type-aware; requires the
89
+ // recommended-type-checked base. configured via the extends above.
90
+ '@typescript-eslint/consistent-type-imports': [
91
+ 'warn',
92
+ { prefer: 'type-imports' }
93
+ ],
94
+ '@typescript-eslint/no-non-null-assertion': 'warn',
95
+ '@typescript-eslint/no-implicit-any': 'warn',
96
+ // G-lint-1 §二 enum → as const: warn-only (escape hatch preserved).
97
+ '@typescript-eslint/no-restricted-syntax': [
98
+ 'warn',
99
+ {
100
+ selector: 'TSEnumDeclaration',
101
+ message: 'Use "as const" union instead of TS enum.'
102
+ }
103
+ ],
104
+
105
+ // L2 (eslint-plugin-import) — boundary hygiene.
106
+ 'import/no-duplicates': 'warn',
107
+ 'import/no-unresolved': 'off',
108
+ 'import/named': 'off',
109
+ 'import/default': 'off',
110
+ 'import/namespace': 'off'
111
+ },
112
+ overrides: [
113
+ {
114
+ files: ['*.test.ts', '*.test.tsx', 'tests/**/*.ts', 'tests/**/*.tsx'],
115
+ rules: {
116
+ 'no-magic-numbers': 'off',
117
+ complexity: 'off',
118
+ 'max-lines-per-function': 'off',
119
+ '@typescript-eslint/no-explicit-any': 'off'
120
+ }
121
+ }
122
+ ]
123
+ };
@@ -30,6 +30,7 @@ import { addJsonOption, printResult } from '../cli-helpers.js';
30
30
  import { findProjectRoot } from '../../services/config/config-safety.js';
31
31
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
32
32
  import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
33
+ import { normalizePath } from '../../shared/path-utils.js';
33
34
  import { containerLeaseFilePath, deserializeContainerLease, finalizeContainerLease, generateContainerLeaseId, markContainerReleased, ttlForContainerRole } from '../../services/container/container-lease.js';
34
35
  const DEFAULT_DOCKER_IMAGE = 'node:22-slim';
35
36
  function detectContainerRuntime(explicit) {
@@ -114,7 +115,7 @@ export function registerContainerCommand(program, io) {
114
115
  // `--label peaks.leaseId=<id>` lets `peaks container
115
116
  // list` / `peaks container gc` find orphans by label
116
117
  // when the lease file is missing.
117
- const cidFile = `${joinPathSession(projectRoot, sessionId).replace(/\\/g, '/')}/.${runtime.runtime}-cid-${leaseId}`;
118
+ const cidFile = `${normalizePath(joinPathSession(projectRoot, sessionId))}/.${runtime.runtime}-cid-${leaseId}`;
118
119
  try {
119
120
  execSync(`${runtime.runtime} run --rm -d --cidfile "${cidFile}" --label "peaks.leaseId=${leaseId}" --label "peaks.rid=${options.rid}" -v "${mount}:/work" -w /work ${image} sleep infinity`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
120
121
  }
@@ -10,6 +10,31 @@ import { getSessionId, setSessionMeta } from '../../../services/session/session-
10
10
  import { resolveCallerProjection } from '../../../services/session/resolve-caller-id.js';
11
11
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
12
12
  import { fail, ok } from 'peaks-loop-shared/result';
13
+ import { stableRealPath } from '../../../shared/path-utils.js';
14
+ /**
15
+ * Canonicalize a user-supplied `--project <path>` value.
16
+ *
17
+ * Git Bash on Windows hands us forward-slash paths
18
+ * (`C:/Users/.../peaks-loop`) while `peaks workspace init` writes the
19
+ * backslash form, and either side may carry a trailing separator or
20
+ * differing case. Resolving to the real path here means every
21
+ * downstream consumer (`getSessionId`, `setSessionMeta`,
22
+ * `setSkillPresence`) sees one stable form.
23
+ *
24
+ * Returns the input unchanged when it cannot be resolved (path does
25
+ * not exist yet, or is not readable) so a bad `--project` still
26
+ * reaches the existing error handling rather than throwing here.
27
+ */
28
+ function canonicalizeProjectOption(project) {
29
+ if (project === undefined)
30
+ return undefined;
31
+ try {
32
+ return stableRealPath(project);
33
+ }
34
+ catch {
35
+ return project;
36
+ }
37
+ }
13
38
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
14
39
  // Slice S0 (4.0.0-beta.5 peaks-solo dispatcher release):
15
40
  // `peaks skill search` is the CLI primitive that feeds the
@@ -143,7 +168,8 @@ export function registerSkillCommand(program, io) {
143
168
  .description('Show the currently active Peaks skill (alias: presence:get)')
144
169
  .option('--check-stale', 'slice 002 (v2.15.0): also report whether the recorded outer session id still matches the current one. Default false (back-compat).')
145
170
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
146
- const presence = getSkillPresence(options.project);
171
+ const projectOption = canonicalizeProjectOption(options.project);
172
+ const presence = getSkillPresence(projectOption);
147
173
  if (presence === null) {
148
174
  printResult(io, ok('skill.presence', { active: false }), options.json);
149
175
  return;
@@ -154,7 +180,7 @@ export function registerSkillCommand(program, io) {
154
180
  // pieces of info from a single CLI invocation. The presence
155
181
  // is returned UNCHANGED — `--check-stale` is a read-only flag,
156
182
  // not a clear.
157
- const staleness = checkStalePresence({ projectRootOverride: options.project });
183
+ const staleness = checkStalePresence({ projectRootOverride: projectOption });
158
184
  printResult(io, ok('skill.presence', {
159
185
  active: true,
160
186
  ...presence,
@@ -173,7 +199,8 @@ export function registerSkillCommand(program, io) {
173
199
  .option('--mode <mode>', 'execution mode')
174
200
  .option('--gate <gate>', 'current gate')
175
201
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
176
- const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
202
+ const projectOption = canonicalizeProjectOption(options.project);
203
+ const projectRoot = projectOption ?? findProjectRoot(process.cwd()) ?? process.cwd();
177
204
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
178
205
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
179
206
  process.exitCode = 1;
@@ -198,11 +225,11 @@ export function registerSkillCommand(program, io) {
198
225
  catch (err) {
199
226
  const message = err instanceof Error ? err.message : String(err);
200
227
  printResult(io, fail('skill.presence:set', 'PEAKS_CALLER_NOT_RESOLVED', `Active IDE adapter could not resolve a callerId (RD §3 D1): ${message}`, { projectRoot, name }, ['Ensure the active IDE is detected by `peaks` and the IDE session variable is set.',
201
- 'Or pass PEAKS_CALLER_ID=<id> or --caller-id <id> for scripted usage.']), options.json);
228
+ 'Or set PEAKS_CALLER_ID=<id> in the environment for scripted usage.']), options.json);
202
229
  process.exitCode = 1;
203
230
  return;
204
231
  }
205
- const presence = setSkillPresence(name, options.mode, options.gate, options.project);
232
+ const presence = setSkillPresence(name, options.mode, options.gate, projectOption);
206
233
  // Session metadata is updated when a session is bound (read-only
207
234
  // path: `getSessionId`). We do not auto-spawn a session.
208
235
  if (boundSessionId !== null) {
@@ -9,11 +9,12 @@ import { proposeFromDoctor } from '../../services/openspec/openspec-propose-from
9
9
  import { runDoctor } from '../../services/doctor/index.js';
10
10
  import { fail, ok } from 'peaks-loop-shared/result';
11
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
12
+ import { normalizePath } from '../../shared/path-utils.js';
12
13
  function resolveScanOptions(project) {
13
14
  if (project === undefined) {
14
15
  return {};
15
16
  }
16
- return { openspecRoot: `${project.replace(/\\/g, '/').replace(/\/$/, '')}/openspec` };
17
+ return { openspecRoot: `${normalizePath(project).replace(/\/$/, '')}/openspec` };
17
18
  }
18
19
  async function loadRenderRequest(requestPath) {
19
20
  const raw = await readFile(requestPath, 'utf8');
@@ -0,0 +1,36 @@
1
+ import type { Reporter } from 'vitest/reporters';
2
+ /** Minimal shape of vitest's TestModule tree node. Vitest 4.1.10 does
3
+ * not export these types publicly; this mirrors the runtime shape
4
+ * the reporter hooks actually receive. */
5
+ interface BddTestModuleLike {
6
+ moduleId?: string;
7
+ relativeModuleId?: string;
8
+ children: {
9
+ tests(): Iterable<BddTestCaseLike>;
10
+ suites(): Iterable<BddTestModuleLike>;
11
+ };
12
+ }
13
+ interface BddTestCaseLike {
14
+ name: string;
15
+ fullName?: string;
16
+ state?: 'passed' | 'failed' | 'skipped';
17
+ result?: () => unknown;
18
+ }
19
+ declare class BddReporter implements Reporter {
20
+ /** Key: relative module id; Value: per-feature rendered scenarios. */
21
+ private readonly features;
22
+ /**
23
+ * Vitest calls `onTestModuleEnd` after a module finishes. We use it
24
+ * to drain the per-module scenarios into the document map and
25
+ * mark the file's pass/fail status.
26
+ */
27
+ onTestModuleEnd(testModule: BddTestModuleLike): void;
28
+ /**
29
+ * Final emit. We deliberately print to stdout with `console.log`
30
+ * (vitest captures stdout when needed) and never call `process.exit`
31
+ * — that is the orchestrator's job. Failure reasons surface as plain
32
+ * text so a downstream LLM prompt can grep for `FAILED:`.
33
+ */
34
+ onTestRunEnd(): void;
35
+ }
36
+ export default BddReporter;
@@ -0,0 +1,159 @@
1
+ // src/reporters/bdd-reporter.ts
2
+ //
3
+ // rid-2026-08-05-bdd-test-style Slice C — vitest custom reporter that
4
+ // emits a pure BDD document view of the run. Designed for business
5
+ // reviewers and downstream LLM prompts; it is NOT a replacement for
6
+ // the default reporter.
7
+ //
8
+ // Why a custom reporter:
9
+ // The default reporter focuses on pass/fail and timing. The BDD
10
+ // reporter transcribes `Feature: <file>` / `Scenario: <describe> ->
11
+ // it` into a single human-readable document so a non-engineer can
12
+ // scan what the suite actually exercises.
13
+ //
14
+ // Why no new dep:
15
+ // vitest 4.1.10 (frozen 2026-07-25) ships the `Reporter` interface
16
+ // in `vitest/reporters`. The custom reporter must have a `default`
17
+ // export — the CLI loads it via `runner.import(path)` and validates
18
+ // `customReporterModule.default` is defined (see vitest cli-api chunks
19
+ // line 11371). Importing the `Reporter` type from vitest does not add
20
+ // a runtime dep; tsc resolves it through vitest's dts shim.
21
+ //
22
+ // Why a flag-only reporter:
23
+ // Per rid design section 4 Slice C, the default vitest run is
24
+ // unchanged. This file is opt-in via:
25
+ //
26
+ // pnpm vitest run --reporter ./src/reporters/bdd-reporter.ts <file>
27
+ //
28
+ // Anti-fake-green rule (CLI silent-catch):
29
+ // The reporter does not swallow vitest result shapes. Every state
30
+ // branch (`passed` / `failed` / `skipped`) is rendered explicitly so
31
+ // downstream reviewers cannot misread a hidden failure.
32
+ //
33
+ // Karpathy note:
34
+ // The reporter deliberately emits ONE document per file with the
35
+ // 4-line Feature/Scenario/Given/When/Then shape — no extra layout
36
+ // metadata, no JSON sidecar. Anything beyond what the spec asked
37
+ // for is excluded by Simplicity First.
38
+ class BddReporter {
39
+ /** Key: relative module id; Value: per-feature rendered scenarios. */
40
+ features = new Map();
41
+ /**
42
+ * Vitest calls `onTestModuleEnd` after a module finishes. We use it
43
+ * to drain the per-module scenarios into the document map and
44
+ * mark the file's pass/fail status.
45
+ */
46
+ onTestModuleEnd(testModule) {
47
+ const moduleId = testModule.relativeModuleId ?? testModule.moduleId ?? '';
48
+ const file = basename(moduleId);
49
+ const scenarios = [];
50
+ collectScenarios(testModule, file, scenarios);
51
+ this.features.set(file, scenarios);
52
+ }
53
+ /**
54
+ * Final emit. We deliberately print to stdout with `console.log`
55
+ * (vitest captures stdout when needed) and never call `process.exit`
56
+ * — that is the orchestrator's job. Failure reasons surface as plain
57
+ * text so a downstream LLM prompt can grep for `FAILED:`.
58
+ */
59
+ onTestRunEnd() {
60
+ const lines = [];
61
+ const features = [];
62
+ for (const [feature, scenarios] of this.features) {
63
+ const ok = scenarios.every((s) => s.state === 'passed' || s.state === 'skipped');
64
+ features.push({ feature, scenarios, ok });
65
+ }
66
+ // Deterministic order: alphabetical by file basename so two runs on
67
+ // the same diff produce byte-identical docs (avoids noisy diffs).
68
+ features.sort((a, b) => a.feature.localeCompare(b.feature));
69
+ for (const f of features) {
70
+ lines.push(`Feature: ${f.feature}`);
71
+ if (f.scenarios.length === 0) {
72
+ // Empty file still surfaces the Feature line so the document
73
+ // is a faithful list of files the runner touched.
74
+ lines.push('');
75
+ continue;
76
+ }
77
+ for (const s of f.scenarios) {
78
+ lines.push(` Scenario: ${s.scenario || '<root>'}`);
79
+ lines.push(` Given ${s.title}`);
80
+ lines.push(` When vitest runs this test`);
81
+ if (s.state === 'passed') {
82
+ lines.push(` Then should pass`);
83
+ }
84
+ else if (s.state === 'skipped') {
85
+ lines.push(` Then should skip`);
86
+ }
87
+ else {
88
+ const reason = s.error ? ` (${truncate(s.error, 200)})` : '';
89
+ lines.push(` Then FAILED: ${s.title}${reason}`);
90
+ }
91
+ }
92
+ lines.push('');
93
+ }
94
+ console.log(lines.join('\n'));
95
+ }
96
+ }
97
+ function basename(path) {
98
+ // vitest module ids are POSIX-style even on Windows; split on '/'
99
+ // then on '\\' as a defensive fallback.
100
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
101
+ return idx === -1 ? path : path.slice(idx + 1);
102
+ }
103
+ /**
104
+ * Walk a `TestModule` recursively and collect rendered scenarios.
105
+ * `describe` blocks contribute their name to the Scenario label;
106
+ * tests declared at module root produce a `<root>` Scenario so the
107
+ * structure is uniform.
108
+ */
109
+ function collectScenarios(entity, file, out) {
110
+ const visited = new WeakSet();
111
+ const walk = (node, scenarioLabel) => {
112
+ if (node === null || typeof node !== 'object')
113
+ return;
114
+ if (visited.has(node))
115
+ return;
116
+ visited.add(node);
117
+ const obj = node;
118
+ if (obj.type === 'test') {
119
+ const tc = node;
120
+ const result = tc.result ? tc.result() : undefined;
121
+ const resultObj = (result ?? {});
122
+ const state = (resultObj.state === 'passed' || resultObj.state === 'failed' || resultObj.state === 'skipped')
123
+ ? resultObj.state
124
+ : 'skipped';
125
+ const err = resultObj.state === 'failed' && resultObj.errors && resultObj.errors[0]
126
+ ? (resultObj.errors[0].message ?? 'unknown failure')
127
+ : undefined;
128
+ out.push({
129
+ scenario: scenarioLabel,
130
+ title: tc.name,
131
+ state,
132
+ error: err,
133
+ });
134
+ return;
135
+ }
136
+ // For a suite/module, descend with the suite's name pushed.
137
+ const suiteName = obj.name ?? '';
138
+ const childSuiteLabel = suiteName || scenarioLabel;
139
+ if (obj.children) {
140
+ try {
141
+ for (const t of obj.children.tests()) {
142
+ walk(t, childSuiteLabel);
143
+ }
144
+ for (const s of obj.children.suites()) {
145
+ walk(s, childSuiteLabel);
146
+ }
147
+ }
148
+ catch {
149
+ // Defensive: vitest internals may throw on teardown. We do not
150
+ // mask the document — we just stop collecting from this node.
151
+ }
152
+ }
153
+ };
154
+ walk(entity, '');
155
+ }
156
+ function truncate(s, n) {
157
+ return s.length <= n ? s : `${s.slice(0, n - 3)}...`;
158
+ }
159
+ export default BddReporter;
@@ -28,6 +28,16 @@ export interface ActiveSkillResolution {
28
28
  readonly skill: string | null;
29
29
  readonly callerId: string | null;
30
30
  readonly sessionId: string | null;
31
+ /**
32
+ * Mode token recorded on the canonical lease (e.g. `full-auto`,
33
+ * `assisted`, `swarm`, `strict`). `null` when the source is not
34
+ * `canonical` (the legacy `active-skill-*.json` files do not
35
+ * surface a mode field, and the `env` / `none` cases are test
36
+ * overrides). Slice 2026-08-04-rid-005 surfaced this so the
37
+ * statusline can render the orchestrator's mode alongside the
38
+ * active leaf role.
39
+ */
40
+ readonly mode: string | null;
31
41
  /** `canonical` = presence-lease-service; `legacy` = per-caller active-skill file; `env` = test override; `none` = nothing wired. */
32
42
  readonly source: 'env' | 'file' | 'canonical' | 'none';
33
43
  }
@@ -42,4 +52,5 @@ export interface ActiveSkillResolution {
42
52
  */
43
53
  export declare function resolveActiveSkillForCaller(projectRoot: string, opts?: {
44
54
  legacyPresence?: boolean;
55
+ callerId?: string | null;
45
56
  }): ActiveSkillResolution;