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
@@ -3,6 +3,8 @@ import { resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
5
5
  import { getSessionIdCanonical } from '../session/session-manager.js';
6
+ import { resolveActiveSkillForCaller } from '../audit/enforcers/active-skill-resolver.js';
7
+ import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
6
8
  /**
7
9
  * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
8
10
  *
@@ -47,11 +49,95 @@ export function parseStatusLineStdin(raw) {
47
49
  return null;
48
50
  }
49
51
  }
52
+ /**
53
+ * Resolve the callerId for the read-side isolation. Order of resolution:
54
+ * 1. `stdin?.caller_id` (when the harness / IDE adapter forwards it)
55
+ * 2. `process.env.CLAUDE_CODE_SESSION_ID` (Claude Code's ambient session id;
56
+ * used as a coarse callerId surrogate when stdin omits caller_id)
57
+ * 3. `null` (no callerId — caller falls back to the project-level
58
+ * single-file read for back-compat)
59
+ */
60
+ function resolveCallerId(stdin) {
61
+ const fromStdin = typeof stdin?.caller_id === 'string' && stdin.caller_id.length > 0
62
+ ? stdin.caller_id
63
+ : null;
64
+ if (fromStdin !== null)
65
+ return fromStdin;
66
+ const fromEnv = process.env['CLAUDE_CODE_SESSION_ID'];
67
+ if (typeof fromEnv === 'string' && fromEnv.length > 0)
68
+ return fromEnv;
69
+ return null;
70
+ }
71
+ /**
72
+ * Read the active-dispatch index for the canonical session, filter to
73
+ * in-flight entries (status NOT IN { done, failed, cancelled, no-execution,
74
+ * never-started, unreadable, stale }), and return the most-recent leaf role
75
+ * plus the total in-flight count. Returns `{ role: null, pendingCount: 0 }`
76
+ * when no session id resolves, the index is empty, or every entry is terminal.
77
+ *
78
+ * READ-ONLY: only reads `.peaks/_sub_agents/<sid>/active-dispatches.json`.
79
+ * Never mutates the on-disk record.
80
+ */
81
+ function readActiveLeaf(projectRoot, sessionId) {
82
+ if (sessionId === null)
83
+ return null;
84
+ let index = {};
85
+ try {
86
+ index = readActiveDispatchIndex(projectRoot, sessionId);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ const terminalStatuses = new Set([
92
+ 'done',
93
+ 'failed',
94
+ 'cancelled',
95
+ 'no-execution',
96
+ 'never-started',
97
+ 'unreadable',
98
+ 'stale',
99
+ 'queued', // Slice 2026-08-05 fix: stale dispatch entries stuck at 'queued' should
100
+ // not pollute statusline as in-flight leaves.
101
+ ]);
102
+ const inFlight = Object.values(index).filter((e) => !terminalStatuses.has(e.status));
103
+ if (inFlight.length === 0)
104
+ return null;
105
+ // Sort by createdAt descending — the most recently dispatched leaf wins.
106
+ const sorted = inFlight.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
107
+ const latest = sorted[0];
108
+ if (latest === undefined)
109
+ return null;
110
+ return { role: latest.role, pendingCount: inFlight.length };
111
+ }
50
112
  /**
51
113
  * Read the presence file without any side effects. Returns null when the file is
52
114
  * absent (idle) and a sentinel object for malformed content (invalid-presence).
115
+ *
116
+ * When `callerId` is non-null, delegates to the canonical lease resolver
117
+ * (presence-lease-service via active-skill-resolver) so the read is
118
+ * session+caller-isolated. When `callerId` is null, falls back to the
119
+ * project-level single-file read (back-compat for callers that don't pass
120
+ * a callerId yet, e.g. legacy CLI invocations).
53
121
  */
54
- function readPresenceReadOnly(projectRoot) {
122
+ function readPresenceReadOnly(projectRoot, callerId) {
123
+ if (callerId !== null) {
124
+ try {
125
+ const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
126
+ if (resolution.source === 'none' || resolution.skill === null) {
127
+ return { presence: null, invalid: false };
128
+ }
129
+ return {
130
+ presence: {
131
+ skill: resolution.skill,
132
+ ...(resolution.mode !== null ? { mode: resolution.mode } : {}),
133
+ },
134
+ invalid: false,
135
+ };
136
+ }
137
+ catch {
138
+ return { presence: null, invalid: true };
139
+ }
140
+ }
55
141
  const presencePath = resolve(projectRoot, PRESENCE_FILE);
56
142
  // Back-compat: prefer the new canonical path; fall back to the legacy
57
143
  // `.peaks/.active-skill.json` for one minor release.
@@ -91,14 +177,16 @@ export function buildStatusLineModel(stdin, nowMs) {
91
177
  // fallbacks). It replaces the active skill content when kind != 'none'.
92
178
  const compact = readCompactState(projectRoot, nowMs);
93
179
  if (projectRoot === null) {
94
- return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact };
180
+ return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null };
95
181
  }
96
- const { presence, invalid } = readPresenceReadOnly(projectRoot);
182
+ // callerId resolves the read-side isolation; back-compat is `null`.
183
+ const callerId = resolveCallerId(stdin);
184
+ const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
97
185
  if (invalid) {
98
- return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact };
186
+ return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
99
187
  }
100
188
  if (presence === null) {
101
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
189
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
102
190
  }
103
191
  // Session binding: when the presence was stamped with a Claude session id and
104
192
  // the live session (from stdin) is a different one, the recorded skill belongs
@@ -107,12 +195,24 @@ export function buildStatusLineModel(stdin, nowMs) {
107
195
  // fall back to the time-based behavior below for backward compatibility.
108
196
  const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
109
197
  if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
110
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
198
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
111
199
  }
112
200
  const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
113
201
  const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
114
202
  const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
115
- return { state, projectRoot, presence, ageMs, compact };
203
+ // Active leaf resolution: read-only query against the per-session
204
+ // active-dispatches index. Filter to in-flight entries; pick the most
205
+ // recent by createdAt. The renderer uses this to surface the in-flight
206
+ // bee skill (e.g. `peaks-rd`) alongside the orchestrator (e.g. `peaks-code`).
207
+ let activeLeaf = null;
208
+ try {
209
+ const sessionId = getSessionIdCanonical(projectRoot);
210
+ activeLeaf = readActiveLeaf(projectRoot, sessionId);
211
+ }
212
+ catch {
213
+ activeLeaf = null;
214
+ }
215
+ return { state, projectRoot, presence, ageMs, compact, activeLeaf };
116
216
  }
117
217
  /**
118
218
  * Read-only compact state resolver. Resolves the canonical session id for the
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { randomBytes } from 'node:crypto';
22
22
  import { posix as path } from 'node:path';
23
+ import { normalizePath } from '../../shared/path-utils.js';
23
24
  export const VM_HYPERVISORS = Object.freeze([
24
25
  'kvm', 'hyperkit', 'hyperv'
25
26
  ]);
@@ -128,7 +129,7 @@ export function deserializeVmLease(raw) {
128
129
  function joinPath(...segments) {
129
130
  if (segments.length === 0)
130
131
  return '';
131
- const normalized = segments.map((s) => s.replace(/\\/g, '/'));
132
+ const normalized = segments.map((s) => normalizePath(s));
132
133
  let acc = normalized[0];
133
134
  for (let i = 1; i < normalized.length; i++) {
134
135
  acc = path.join(acc, normalized[i]);
@@ -20,6 +20,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
20
20
  // `.peaks/_runtime/<sessionId>/<role>/...` string is no longer
21
21
  // pre-built.
22
22
  import { getSessionDir } from '../session/getSessionDir.js';
23
+ import { normalizePath } from '../../shared/path-utils.js';
23
24
  const MAX_RESUME_ARTIFACT_BYTES = 256_000;
24
25
  // Slice 2026-06-29-change-id-root-removal: return the bare role-relative
25
26
  // sub-paths instead of `.peaks/_runtime/change/<id>/<role>/...` strings.
@@ -65,7 +66,7 @@ function normalizeRoleRelativePath(artifact, _sessionId) {
65
66
  // the path separators and strips any leading `/`; the `sessionId`
66
67
  // argument is preserved on the signature for backward call-site
67
68
  // compatibility but is no longer embedded in the path.
68
- return artifact.replace(/\\/g, '/').replace(/^\/+/, '');
69
+ return normalizePath(artifact).replace(/^\/+/, '');
69
70
  }
70
71
  function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
71
72
  // Slice 2026-06-29-change-id-root-removal: on-disk home now lives under
@@ -73,7 +74,7 @@ function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
73
74
  // sub-path (e.g. `rd/swarm/checkpoints/checkpoint-1.json`) is
74
75
  // strictly a sub-root drill, not a top-level dir derivation.
75
76
  const sessionScopeRoot = getSessionDir(artifactWorkspacePath, sessionId);
76
- const normalizedArtifact = artifact.replace(/\\/g, '/').replace(/^\/+/, '');
77
+ const normalizedArtifact = normalizePath(artifact).replace(/^\/+/, '');
77
78
  const artifactPath = resolve(sessionScopeRoot, normalizedArtifact);
78
79
  try {
79
80
  const artifactWorkspaceRealPath = realpathSync(artifactWorkspacePath);
@@ -3,6 +3,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { isDirectory } from 'peaks-loop-shared/fs';
5
5
  import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
6
+ import { normalizePath } from '../../shared/path-utils.js';
6
7
  /**
7
8
  * Slice 2026-06-29-change-id-root-removal: list the immediate children of
8
9
  * `.peaks/` so the legacy sibling-dir guard can enumerate date-stamped
@@ -418,7 +419,7 @@ export function isWriterCreatedSiblingShape(siblingDir) {
418
419
  continue;
419
420
  }
420
421
  if (stat.isFile()) {
421
- const rel = node.rel.replace(/\\/g, '/');
422
+ const rel = normalizePath(node.rel);
422
423
  const matches = WRITER_ALLOWED_RELATIVE_PATTERNS.some((re) => re.test(rel));
423
424
  if (!matches) {
424
425
  return false;
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { randomBytes } from 'node:crypto';
28
28
  import { posix as path } from 'node:path';
29
+ import { normalizePath } from '../../shared/path-utils.js';
29
30
  /**
30
31
  * Per-role default TTL. Sub-agent dispatch duration varies by role:
31
32
  * - rd (long planning + impl) → 30 min
@@ -233,7 +234,7 @@ export function deserializeLease(raw) {
233
234
  function joinPath(...segments) {
234
235
  if (segments.length === 0)
235
236
  return '';
236
- const normalized = segments.map((s) => s.replace(/\\/g, '/'));
237
+ const normalized = segments.map((s) => normalizePath(s));
237
238
  let acc = normalized[0];
238
239
  for (let i = 1; i < normalized.length; i++) {
239
240
  acc = path.join(acc, normalized[i]);
@@ -10,11 +10,9 @@
10
10
  * check without bringing in the change-id axis.
11
11
  */
12
12
  import { posix } from 'node:path';
13
- function normalizeForwardSlashes(input) {
14
- return input.replace(/\\/g, '/');
15
- }
13
+ import { normalizePath } from './path-utils.js';
16
14
  function hasUnsafePathShape(input) {
17
- const normalized = normalizeForwardSlashes(input);
15
+ const normalized = normalizePath(input);
18
16
  if (input.includes('\\'))
19
17
  return true;
20
18
  if (!normalized || normalized === '.' || normalized === '..')
@@ -36,7 +34,7 @@ export function isUnsafeArtifactPath(path) {
36
34
  return isUnsafePathInput(path);
37
35
  }
38
36
  function normalizeArtifactPath(input) {
39
- const normalized = posix.normalize(normalizeForwardSlashes(input));
37
+ const normalized = posix.normalize(normalizePath(input));
40
38
  return normalized.replace(/\/$/, '');
41
39
  }
42
40
  export { normalizeArtifactPath as _normalizeArtifactPath };
@@ -11,3 +11,51 @@ export declare function isWindowsAbsolutePath(path: string): boolean;
11
11
  export declare function resolveInputPath(path: string): string;
12
12
  export declare function stableRealPath(path: string): string;
13
13
  export declare function stablePath(path: string): string;
14
+ /**
15
+ * Build the comparison key for a `projectRoot` value. Two paths that
16
+ * denote the same physical directory MUST produce the same key, and two
17
+ * paths that denote different directories MUST NOT.
18
+ *
19
+ * Composed from the existing `path-utils.ts` primitives; no new
20
+ * dependency. Three normalizations, in order:
21
+ *
22
+ * 1. **Symlink resolution** via `stableRealPath` — collapses macOS
23
+ * `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
24
+ * symlink to `/private/var`). Falls back to `resolveInputPath`
25
+ * when the path does not exist, so this never throws for callers
26
+ * that pass a not-yet-created root (or a stale binding whose
27
+ * directory has since been deleted).
28
+ * 2. **Separator folding** via `normalizePath` — collapses the
29
+ * Windows `C:\Users\...` (written by `peaks workspace init`) vs
30
+ * `C:/Users/...` (passed by a Git Bash caller) split that was the
31
+ * original `PEAKS_SESSION_NOT_BOUND` trigger.
32
+ * 3. **Case folding, Windows only** — NTFS is case-insensitive, so
33
+ * `C:\Foo` and `c:/foo` are the same directory. This step is
34
+ * guarded on `isWindows` because POSIX filesystems are
35
+ * case-SENSITIVE: folding case on Linux would make the genuinely
36
+ * distinct `/tmp/Foo` and `/tmp/foo` compare equal.
37
+ *
38
+ * Note on step 3: `realpathSync` alone is NOT sufficient for the
39
+ * Windows case split. Verified empirically on Node 22 / win32 —
40
+ * `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
41
+ * `realpathSync.native` case-folds. We do not switch to `.native`
42
+ * because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
43
+ * which would change the on-disk form of every existing binding.
44
+ * Explicit `toLowerCase()` is the narrower, more predictable fix.
45
+ *
46
+ * This is a compare key ONLY — never persist it. The value written to
47
+ * disk stays the real path (see `writeSessionFile`).
48
+ *
49
+ * Lifted from `src/services/session/session-manager.ts` in slice
50
+ * `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
51
+ * and `session-binding-bridge.ts` share a single canonicalization
52
+ * authority.
53
+ */
54
+ export declare function projectRootCompareKey(p: string): string;
55
+ /**
56
+ * Compare two `projectRoot` values under the canonicalization rules
57
+ * documented on `projectRootCompareKey`. Returns true iff both paths
58
+ * canonicalize to the same key — covers symlink resolution, separator
59
+ * folding, and (Windows only) case folding.
60
+ */
61
+ export declare function projectRootsMatch(a: string, b: string): boolean;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
2
  import { isAbsolute, parse, relative, resolve, sep } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
- import { platform } from './platform.js';
4
+ import { isWindows, platform } from './platform.js';
5
5
  export const SEP = sep;
6
6
  const localPathConverters = {
7
7
  win32: (p) => p.replace(/\//g, '\\'),
@@ -54,3 +54,67 @@ export function stablePath(path) {
54
54
  const realExistingPath = existsSync(currentPath) ? stableRealPath(currentPath) : parsedPath.root;
55
55
  return resolve(realExistingPath, ...missingSegments);
56
56
  }
57
+ /**
58
+ * Build the comparison key for a `projectRoot` value. Two paths that
59
+ * denote the same physical directory MUST produce the same key, and two
60
+ * paths that denote different directories MUST NOT.
61
+ *
62
+ * Composed from the existing `path-utils.ts` primitives; no new
63
+ * dependency. Three normalizations, in order:
64
+ *
65
+ * 1. **Symlink resolution** via `stableRealPath` — collapses macOS
66
+ * `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
67
+ * symlink to `/private/var`). Falls back to `resolveInputPath`
68
+ * when the path does not exist, so this never throws for callers
69
+ * that pass a not-yet-created root (or a stale binding whose
70
+ * directory has since been deleted).
71
+ * 2. **Separator folding** via `normalizePath` — collapses the
72
+ * Windows `C:\Users\...` (written by `peaks workspace init`) vs
73
+ * `C:/Users/...` (passed by a Git Bash caller) split that was the
74
+ * original `PEAKS_SESSION_NOT_BOUND` trigger.
75
+ * 3. **Case folding, Windows only** — NTFS is case-insensitive, so
76
+ * `C:\Foo` and `c:/foo` are the same directory. This step is
77
+ * guarded on `isWindows` because POSIX filesystems are
78
+ * case-SENSITIVE: folding case on Linux would make the genuinely
79
+ * distinct `/tmp/Foo` and `/tmp/foo` compare equal.
80
+ *
81
+ * Note on step 3: `realpathSync` alone is NOT sufficient for the
82
+ * Windows case split. Verified empirically on Node 22 / win32 —
83
+ * `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
84
+ * `realpathSync.native` case-folds. We do not switch to `.native`
85
+ * because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
86
+ * which would change the on-disk form of every existing binding.
87
+ * Explicit `toLowerCase()` is the narrower, more predictable fix.
88
+ *
89
+ * This is a compare key ONLY — never persist it. The value written to
90
+ * disk stays the real path (see `writeSessionFile`).
91
+ *
92
+ * Lifted from `src/services/session/session-manager.ts` in slice
93
+ * `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
94
+ * and `session-binding-bridge.ts` share a single canonicalization
95
+ * authority.
96
+ */
97
+ export function projectRootCompareKey(p) {
98
+ let canonical;
99
+ try {
100
+ canonical = stableRealPath(p);
101
+ }
102
+ catch {
103
+ // Path does not exist (yet). Fall back to the resolved absolute
104
+ // form so a binding written before its directory exists — and a
105
+ // binding whose directory was later deleted — still compares by
106
+ // separator + case rather than throwing.
107
+ canonical = resolveInputPath(p);
108
+ }
109
+ const separatorFolded = normalizePath(canonical);
110
+ return isWindows ? separatorFolded.toLowerCase() : separatorFolded;
111
+ }
112
+ /**
113
+ * Compare two `projectRoot` values under the canonicalization rules
114
+ * documented on `projectRootCompareKey`. Returns true iff both paths
115
+ * canonicalize to the same key — covers symlink resolution, separator
116
+ * folding, and (Windows only) case folding.
117
+ */
118
+ export function projectRootsMatch(a, b) {
119
+ return projectRootCompareKey(a) === projectRootCompareKey(b);
120
+ }
@@ -0,0 +1,135 @@
1
+ # Test-Style Contract for LLM-Written Unit Tests
2
+
3
+ > **Effective**: rid-2026-08-05-bdd-test-style, peaks-loop v4.0.11+
4
+ > **Audience**: LLM agents (peaks-rd / peaks-qa / downstream consumers) that
5
+ > write new unit tests in projects adopting this contract.
6
+ > **Status**: soft contract — enforced at peaks-qa verification time, not
7
+ > at compile time.
8
+
9
+ This document is the opt-in LLM-facing counterpart to the AAA→BDD
10
+ rewrite that the rid-2026-08-05-bdd-test-style slices ship inside
11
+ peaks-loop. Downstream projects can adopt the same contract by
12
+ importing this file:
13
+
14
+ ```ts
15
+ import contract from 'peaks-loop/test-style';
16
+ ```
17
+
18
+ The runtime export is the markdown string below; the contract lives
19
+ in the prose, not in code. LLM agents are expected to read this file
20
+ on every test-writing turn.
21
+
22
+ ---
23
+
24
+ ## 1. The Contract
25
+
26
+ Every new or modified `it()` / `test()` block in `tests/unit/**`
27
+ MUST follow the given-when-then shape.
28
+
29
+ ### 1.1 Description (the `it()` / `test()` string-literal)
30
+
31
+ - **Form**: `when X, should Y` — state the precondition in the `when`
32
+ clause and the observable outcome in the `should` clause.
33
+ - **Required word**: must include at least one of `when` or `should`.
34
+ (`when` alone is the precondition; `should` alone is the outcome;
35
+ the natural-language form combines both.)
36
+ - **Anti-pattern**: do NOT use legacy `// arrange:` / `// act:` /
37
+ `// assert:` markers anywhere in the test body.
38
+
39
+ ### 1.2 Body (the callback)
40
+
41
+ The first three statements of the callback (after the opening brace,
42
+ before any executable code) MUST be exactly three leading comments:
43
+
44
+ ```ts
45
+ // given: <precondition — system / user state>
46
+ // when: <action — what is invoked>
47
+ // then: <expected outcome — what is asserted>
48
+ ```
49
+
50
+ The `then:` line corresponds to the assertion(s) that follow.
51
+
52
+ ### 1.3 Behavior preservation
53
+
54
+ The migration must be idempotent. A second pass over a test file
55
+ already in BDD form must produce the same output (no duplicate
56
+ comment blocks, no double-tagged descriptions).
57
+
58
+ ---
59
+
60
+ ## 2. 5-Item Pre-Write Checklist
61
+
62
+ LLM agents writing a new test should run this checklist before
63
+ declaring the test complete:
64
+
65
+ 1. **Does the description include `when` or `should`?**
66
+ If no, rewrite the description before writing the body.
67
+ 2. **Does the body start with the `// given:` / `// when:` / `// then:`
68
+ triple in that exact order?**
69
+ If no, prepend the missing lines.
70
+ 3. **Are there any `// arrange:` / `// act:` / `// assert:` lines?**
71
+ If yes, replace them with the BDD triple.
72
+ 4. **Will the test still pass with the BDD rewrite?**
73
+ Run the test, do not just trust the diff. Anti-fake-green rule:
74
+ vitest green is necessary but not sufficient.
75
+ 5. **Does the description read as business behavior, not as
76
+ implementation detail?**
77
+ If the description reads like code (e.g. "calls foo with x"),
78
+ rewrite it as observable behavior ("when x is passed, should
79
+ return y").
80
+
81
+ ---
82
+
83
+ ## 3. Opt-In Adoption (downstream projects)
84
+
85
+ Downstream consumers can adopt the same contract without depending
86
+ on peaks-loop at runtime — this file is the contract. To opt in:
87
+
88
+ ```jsonc
89
+ // package.json
90
+ {
91
+ "devDependencies": {
92
+ "peaks-loop": "^4.0.11"
93
+ }
94
+ }
95
+ ```
96
+
97
+ ```ts
98
+ // In a vitest setup or in a pre-commit hook:
99
+ import contract from 'peaks-loop/test-style';
100
+ // `contract` is the markdown string of this document; surface it to
101
+ // your LLM agent on every test-writing turn via system prompt or
102
+ // tool description.
103
+ ```
104
+
105
+ The contract is intentionally **not** a code-level dependency — it
106
+ is a document that LLMs read. Runtime imports are an opt-in
107
+ ergonomic aid for surfacing the contract to a downstream prompt.
108
+
109
+ ---
110
+
111
+ ## 4. Why Not Enforce in the Test Runner?
112
+
113
+ - vitest's `it()` accepts any string; enforcing description shape at
114
+ runtime would require a custom wrapper around every test, which
115
+ defeats vitest's plugin compatibility.
116
+ - AST-based verification (the peaks-qa `bdd-test-style-verifier`) is
117
+ the chosen gate because it inspects what the LLM wrote, not what
118
+ vitest sees. False positives from string-internal `when` matches
119
+ are eliminated by walking only the description's `StringLiteral`
120
+ and the body callback's leading comments.
121
+ - LLM-authored tests are the primary audience. Humans writing tests
122
+ are not blocked by this contract (peaks-qa is the gate, not vitest).
123
+
124
+ ---
125
+
126
+ ## 5. Author & Change Control
127
+
128
+ - **Author**: SquabbyZ (`601709253@qq.com`) — sole-author per project
129
+ red rule.
130
+ - **Change control**: any edit to this file MUST go through a
131
+ peaks-rd slice with peaks-qa acceptance; treat the contract text as
132
+ load-bearing for downstream LLM behavior.
133
+ - **Related**: `.peaks/_runtime/2026-08-04-session-3fe1be/sc/2026-08-05-bdd-test-style-rid-design.md`
134
+ (the design doc), `scripts/migrate-to-bdd.mjs` (the AST migrator),
135
+ `src/services/qa/bdd-test-style-verifier.ts` (the verifier).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.9",
3
+ "version": "4.0.11",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -82,6 +82,8 @@
82
82
  "agents/**",
83
83
  "schemas/*.json",
84
84
  ".claude-plugin/**",
85
+ "config/eslint/.peaks-rules.cjs",
86
+ "docs/test-style-contract.md",
85
87
  "README.md",
86
88
  "README-en.md",
87
89
  "CHANGELOG.md",
@@ -98,9 +100,9 @@
98
100
  "headroom-ai": "0.22.4",
99
101
  "yaml": "^2.9.0",
100
102
  "zod": "^3.25.76",
103
+ "peaks-loop-shared-channel": "0.0.19",
101
104
  "peaks-loop-mut": "0.1.15",
102
- "peaks-loop-shared": "0.0.40",
103
- "peaks-loop-shared-channel": "0.0.19"
105
+ "peaks-loop-shared": "0.0.42"
104
106
  },
105
107
  "peerDependencies": {
106
108
  "@alibaba-group/open-code-review": "1.3.1"
@@ -65,4 +65,20 @@ The dispatch CLI (`peaks sub-agent dispatch`) automatically prepends a Test Tool
65
65
 
66
66
  If the framework is not obvious from `package.json#scripts.test`, the sub-agent should run `peaks test --json` to introspect the resolved framework + argv before picking a runner.
67
67
 
68
- See the block constant at `src/services/dispatch/test-tool-detection.ts` for the verbatim text.
68
+ See the block constant at `src/services/dispatch/test-tool-detection.ts` for the verbatim text.
69
+
70
+ ## BDD Test Style Verification (effective rid-2026-08-05-bdd-test-style, v4.0.11+)
71
+
72
+ When you (peaks-qa) verify a slice, you MUST run the BDD test-style verifier on every new or modified `tests/unit/**/*.test.ts` file in the slice's git diff. Use:
73
+
74
+ ```bash
75
+ node -e "
76
+ const { verifyBddStyle } = await import('./src/services/qa/bdd-test-style-verifier.ts');
77
+ const { execSync } = require('node:child_process');
78
+ const files = execSync('git diff --name-only HEAD~1 -- tests/unit', { encoding: 'utf8' })
79
+ .split('\n').filter(f => f.endsWith('.test.ts'));
80
+ console.log(JSON.stringify(verifyBddStyle({ projectRoot: '.', testFiles: files })));
81
+ "
82
+ ```
83
+
84
+ If the verifier returns `ok: false`, your verdict MUST be `failed: bdd-style-violation` with the structured reason from the verifier (do NOT mark the slice as passing).
@@ -147,4 +147,24 @@ Touch only what you must. Clean up only your own mess. When editing existing cod
147
147
  Define success criteria. Loop until verified. "Add validation" → write tests for invalid inputs, then make them pass. "Fix the bug" → write a test that reproduces it, then make it pass. For multi-step tasks, state a brief plan with verify checkpoints. Strong success criteria let you loop independently. Weak criteria require constant clarification.
148
148
  ```
149
149
 
150
- Sub-agents MUST NOT silently drop this block. The regression test `tests/unit/skills/karpathy-prompt-injection.test.ts` asserts this block is present. The canonical skill id for the full guidelines text is `andrej-karpathy-skills:karpathy-guidelines`.
150
+ Sub-agents MUST NOT silently drop this block. The regression test `tests/unit/skills/karpathy-prompt-injection.test.ts` asserts this block is present. The canonical skill id for the full guidelines text is `andrej-karpathy-skills:karpathy-guidelines`.
151
+
152
+ ## BDD Test Style Contract (effective rid-2026-08-05-bdd-test-style, v4.0.11+)
153
+
154
+ When you (the LLM sub-agent) write new or modified unit tests in `tests/unit/**`, every `it()` / `test()` block MUST follow the given-when-then contract:
155
+
156
+ 1. The first string-literal argument of `it()` / `test()` MUST describe business behavior in the form `when X, should Y` — must include either the word "when" (state / pre-condition) or "should" (observable outcome).
157
+ 2. The body callback (the second argument) MUST start with exactly 3 leading comments:
158
+ ```typescript
159
+ // given: <precondition — system / user state>
160
+ // when: <action — what is invoked>
161
+ // then: <expected outcome — what is asserted>
162
+ ```
163
+ 3. Legacy `// arrange:` / `// act:` / `// assert:` AAA markers MUST NOT appear in tests you write.
164
+
165
+ Run `node scripts/migrate-to-bdd.mjs --dry-run <file>` before writing new tests to inspect the contract, or use the Slice B verifier's rule directly:
166
+
167
+ - description: must contain `/(\bwhen\b|\bshould\b)/`
168
+ - first 3 body comments: must match `/^\s*\/\/\s*given\s*:/`, `/^\s*\/\/\s*when\s*:/`, `/^\s*\/\/\s*then\s*:/`
169
+
170
+ If your tests fail peaks-qa's `bdd-test-style-verifier` (see Slice B), the slice will be returned-to-rd. Fix the violations, do NOT bypass the check.