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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
14
|
-
return input.replace(/\\/g, '/');
|
|
15
|
-
}
|
|
13
|
+
import { normalizePath } from './path-utils.js';
|
|
16
14
|
function hasUnsafePathShape(input) {
|
|
17
|
-
const normalized =
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|