peaks-loop 4.0.10 → 4.0.12

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 +57 -0
  2. package/config/eslint/.peaks-rules.cjs +123 -0
  3. package/dist/cli/commands/code-commands.js +2 -0
  4. package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
  5. package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
  6. package/dist/cli/commands/workspace/reconcile-command.js +5 -2
  7. package/dist/reporters/bdd-reporter.d.ts +36 -0
  8. package/dist/reporters/bdd-reporter.js +159 -0
  9. package/dist/services/audit/enforcers/active-skill-resolver.js +4 -1
  10. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  11. package/dist/services/code/orchestrator-can-do.js +205 -0
  12. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  13. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  14. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  15. package/dist/services/doctor/doctor-service/types.d.ts +9 -0
  16. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  17. package/dist/services/hooks/presence-marker-detector.js +45 -85
  18. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  19. package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
  20. package/dist/services/qa/bdd-test-style-verifier.js +268 -0
  21. package/dist/services/sc/sc-service.d.ts +11 -7
  22. package/dist/services/sc/sc-service.js +29 -26
  23. package/dist/services/session/binding-store.d.ts +6 -6
  24. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  25. package/dist/services/skills/hooks-settings-service.js +1 -1
  26. package/dist/services/skills/presence-lease-service.js +1 -0
  27. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  28. package/dist/services/skills/skill-presence-service.js +112 -102
  29. package/dist/services/skills/skill-statusline-service.js +73 -39
  30. package/docs/test-style-contract.md +135 -0
  31. package/package.json +6 -4
  32. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
  33. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +21 -1
  34. package/skills/peaks-code/SKILL.md +6 -0
  35. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -2,7 +2,15 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
5
- import { getSessionMeta } from '../session/session-manager.js';
5
+ import { getSessionId, getSessionMeta } from '../session/session-manager.js';
6
+ // Slice 4.0.8 compat shim: the canonical write path lives in
7
+ // `presence-lease-service.ts`. The legacy shim dynamically imports
8
+ // it inside `setSkillPresence` so the cold path of the legacy
9
+ // `getSkillPresence` / `checkStalePresence` / `clearStalePresenceOnRotation`
10
+ // read paths stays cheap. The static imports below are reserved for
11
+ // the migration window and are referenced through the dynamic
12
+ // `leaseMod.*` accessor at runtime.
13
+ import { listPresenceLeases } from './presence-lease-service.js';
6
14
  // Re-export the 4.0.8 compat surface so legacy callers
7
15
  // (`presence-service` consumers in `code-mode-gate-commands.ts`,
8
16
  // `mode-enforcement.ts`, `code-job-shape-commands.ts`, etc.) keep
@@ -37,14 +45,13 @@ function getCurrentOuterSessionId() {
37
45
  return claude;
38
46
  return undefined;
39
47
  }
40
- // As of slice 2026-06-05-peaks-runtime-layer the orchestrator's
41
- // active-skill marker lives under `.peaks/_runtime/active-skill.json`.
42
- // The legacy `.peaks/.active-skill.json` path is preserved as a
43
- // read-only fallback for one minor release so older CLI versions (or
44
- // trees that have not been migrated by `peaks workspace reconcile`)
45
- // keep working without a forced re-init.
46
- const PRESENCE_FILE = join('.peaks', '_runtime', 'active-skill.json');
47
- const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
48
+ // Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
49
+ // project-level single-slot presence files
50
+ // `.peaks/_runtime/active-skill.json` and `.peaks/.active-skill.json`
51
+ // are REMOVED from this module. The canonical sid-scoped lease
52
+ // projection (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the
53
+ // only source of truth. The legacy constants `PRESENCE_FILE` and
54
+ // `PRESENCE_FILE_LEGACY` are deleted; readers use `listPresenceLeases`.
48
55
  const SESSION_FILE = join('.peaks', '_runtime', 'session.json');
49
56
  const SESSION_FILE_LEGACY = '.peaks/.session.json';
50
57
  function resolveProjectRoot(override) {
@@ -52,36 +59,6 @@ function resolveProjectRoot(override) {
52
59
  return resolve(override);
53
60
  return findProjectRoot(process.cwd()) ?? process.cwd();
54
61
  }
55
- function resolvePresencePath(projectRootOverride) {
56
- return resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE);
57
- }
58
- /**
59
- * Back-compat read for the active-skill marker. Prefers the new
60
- * canonical `.peaks/_runtime/active-skill.json`; falls back to the
61
- * legacy `.peaks/.active-skill.json` for one minor release.
62
- *
63
- * Returns the parsed SkillPresence object, or null when neither
64
- * file is present / valid. The legacy file is never written by
65
- * current code — only the new path receives writes.
66
- */
67
- function readSkillPresenceBackCompat(projectRootOverride) {
68
- const presencePath = resolvePresencePath(projectRootOverride);
69
- const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
70
- const pathToRead = existsSync(presencePath) ? presencePath : legacyPath;
71
- if (!existsSync(pathToRead))
72
- return null;
73
- try {
74
- const raw = readFileSync(pathToRead, 'utf8');
75
- const parsed = JSON.parse(raw);
76
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
77
- return null;
78
- }
79
- return { presence: parsed, path: pathToRead };
80
- }
81
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
82
- return null;
83
- }
84
- }
85
62
  /**
86
63
  * Resolve the active peaks session id from
87
64
  * `.peaks/_runtime/session.json` (legacy: `.peaks/.session.json`).
@@ -112,6 +89,43 @@ export function getCurrentSessionId(projectRootOverride) {
112
89
  return null;
113
90
  }
114
91
  }
92
+ /**
93
+ * Slice 4.0.11 statusline-sid-scoped-lease C: read the canonical
94
+ * sid-scoped lease projection and project the freshest in-flight
95
+ * lease into the legacy `SkillPresence` shape that the public
96
+ * `getSkillPresence` / `checkStalePresence` /
97
+ * `clearStalePresenceOnRotation` consumers expect. The projection
98
+ * is lossy (the lease does not carry `outerSessionId`; `setAt`
99
+ * maps to `startedAt`; `lastHeartbeat` is reused as `lastHeartbeat`).
100
+ *
101
+ * Returns `null` when the bound session has no in-flight leases
102
+ * (`running` or `preparing`); terminalized leases are ignored so
103
+ * the read does not resurrect a stale skill on a fresh session.
104
+ */
105
+ function readSkillPresenceFromLease(projectRootOverride) {
106
+ const projectRoot = resolveProjectRoot(projectRootOverride);
107
+ const sessionId = getSessionId(projectRoot);
108
+ if (sessionId === null)
109
+ return null;
110
+ const leases = listPresenceLeases(projectRoot, sessionId);
111
+ const inFlight = leases.filter((l) => l.status === 'running' || l.status === 'preparing');
112
+ if (inFlight.length === 0)
113
+ return null;
114
+ inFlight.sort((a, b) => b.lastHeartbeat.localeCompare(a.lastHeartbeat));
115
+ const lease = inFlight[0];
116
+ if (lease === undefined)
117
+ return null;
118
+ // The lease carries `callerId` (the adapter / harness session id),
119
+ // not the legacy `outerSessionId` field. Project it for back-compat
120
+ // with `clearStalePresenceOnRotation`'s outer-mismatch check.
121
+ return {
122
+ skill: lease.skill,
123
+ sessionId,
124
+ outerSessionId: lease.callerId,
125
+ setAt: lease.startedAt,
126
+ lastHeartbeat: lease.lastHeartbeat
127
+ };
128
+ }
115
129
  /**
116
130
  * Look up the outer-session-id that was bound to the *current* peaks
117
131
  * session, i.e. the one written to the per-session
@@ -160,23 +174,14 @@ function getBoundOuterSessionId(projectRootOverride) {
160
174
  * legacy `.peaks/.active-skill.json` for one minor release.
161
175
  */
162
176
  function getPreviousOuterSessionId(projectRootOverride) {
163
- const result = readSkillPresenceBackCompat(projectRootOverride);
177
+ const result = readSkillPresenceFromLease(projectRootOverride);
164
178
  if (result === null)
165
179
  return undefined;
166
- const parsed = result.presence;
167
- if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
168
- return parsed.outerSessionId;
169
- }
170
- // Legacy field name. Honour it on the read side so v1.2.x
171
- // presence files do not show as a false mismatch.
172
- if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
173
- return parsed.claudeSessionId;
180
+ if (typeof result.outerSessionId === 'string' && result.outerSessionId.length > 0) {
181
+ return result.outerSessionId;
174
182
  }
175
183
  return undefined;
176
184
  }
177
- export function exportSkillPresence(projectRootOverride) {
178
- return resolvePresencePath(projectRootOverride);
179
- }
180
185
  // ============================================================================
181
186
  // Slice 020 — caller-keyed active-skill marker (D6).
182
187
  // ============================================================================
@@ -336,12 +341,12 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
336
341
  catch { /* fall through to legacy write */ }
337
342
  })();
338
343
  }
339
- const presencePath = resolvePresencePath(projectRootOverride);
340
- const presenceDir = dirname(presencePath);
341
- if (!existsSync(presenceDir)) {
342
- mkdirSync(presenceDir, { recursive: true });
343
- }
344
- writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
344
+ // Slice 4.0.11 statusline-sid-scoped-lease A: the canonical write
345
+ // path is now exclusively `presence-lease-service.setPresenceLease`.
346
+ // The legacy single-slot marker file write was removed in this
347
+ // slice: every `setSkillPresence` call flows through the sid-scoped
348
+ // lease + presence-index machinery above. The legacy
349
+ // `active-skill.json` file is NOT recreated here.
345
350
  // Skill-activation side effect: ensure `.peaks/memory/` and a full-shape
346
351
  // empty `index.json` exist for the project. This is the user-facing fix
347
352
  // for "stock projects never get a memory directory or index". Every peaks
@@ -354,26 +359,17 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
354
359
  return presence;
355
360
  }
356
361
  export function getSkillPresence(projectRootOverride) {
357
- const result = readSkillPresenceBackCompat(projectRootOverride);
358
- if (result === null)
359
- return null;
360
- const { presence, path: presencePath } = result;
361
- if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
362
- const currentSessionId = getCurrentSessionId(projectRootOverride);
363
- if (currentSessionId && presence.sessionId !== currentSessionId) {
364
- try {
365
- unlinkSync(presencePath);
366
- }
367
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
368
- // best effort
369
- }
370
- return null;
371
- }
372
- }
373
- return presence;
362
+ // Slice 4.0.11 statusline-sid-scoped-lease C: read from the
363
+ // canonical sid-scoped lease projection. The legacy
364
+ // `unlinkSync(presencePath)` purge on session-mismatch is
365
+ // removed: the lease projection is keyed off the bound session
366
+ // id, so a mismatch cannot happen — `listPresenceLeases`
367
+ // already filters by the session-id resolved from the workspace
368
+ // binding.
369
+ return readSkillPresenceFromLease(projectRootOverride);
374
370
  }
375
371
  export function checkStalePresence(opts) {
376
- const result = readSkillPresenceBackCompat(opts?.projectRootOverride);
372
+ const result = readSkillPresenceFromLease(opts?.projectRootOverride);
377
373
  // `opts?.currentOuter === undefined` is the omitted-key case. A
378
374
  // falsy string `''` is an explicit "no signal" (used by tests to
379
375
  // simulate a CLI run with no harness env vars).
@@ -389,7 +385,7 @@ export function checkStalePresence(opts) {
389
385
  recordedOuterSessionId: undefined
390
386
  };
391
387
  }
392
- const recorded = result.presence.outerSessionId;
388
+ const recorded = result.outerSessionId;
393
389
  // Suppress false-positives when NEITHER side recorded an outer
394
390
  // session id (legacy project, no harness signal). Two unknowns
395
391
  // are not a swap — they are "no signal available yet".
@@ -399,7 +395,7 @@ export function checkStalePresence(opts) {
399
395
  return {
400
396
  stale: false,
401
397
  reason: null,
402
- presence: result.presence,
398
+ presence: result,
403
399
  currentOuterSessionId: current,
404
400
  recordedOuterSessionId: recorded
405
401
  };
@@ -408,7 +404,7 @@ export function checkStalePresence(opts) {
408
404
  return {
409
405
  stale: false,
410
406
  reason: null,
411
- presence: result.presence,
407
+ presence: result,
412
408
  currentOuterSessionId: current,
413
409
  recordedOuterSessionId: recorded
414
410
  };
@@ -416,7 +412,7 @@ export function checkStalePresence(opts) {
416
412
  return {
417
413
  stale: true,
418
414
  reason: 'outer-session-mismatch',
419
- presence: result.presence,
415
+ presence: result,
420
416
  currentOuterSessionId: current,
421
417
  recordedOuterSessionId: recorded
422
418
  };
@@ -444,11 +440,11 @@ export function checkStalePresence(opts) {
444
440
  * decide what the "rotated-out" id was.
445
441
  */
446
442
  export function clearStalePresenceOnRotation(opts) {
447
- const result = readSkillPresenceBackCompat(opts.projectRootOverride);
443
+ const result = readSkillPresenceFromLease(opts.projectRootOverride);
448
444
  if (result === null) {
449
445
  return { cleared: false, reason: 'no-presence' };
450
446
  }
451
- const recorded = result.presence.outerSessionId;
447
+ const recorded = result.outerSessionId;
452
448
  const current = opts.currentOuterSessionId;
453
449
  // If the recorded outer id matches the new (current) outer id,
454
450
  // this presence is NOT stale — the user just reconnected from
@@ -483,24 +479,25 @@ export function clearStalePresenceOnRotation(opts) {
483
479
  };
484
480
  }
485
481
  export function touchSkillHeartbeat(projectRootOverride) {
486
- const result = readSkillPresenceBackCompat(projectRootOverride);
487
- if (result === null)
482
+ // Slice 4.0.11 statusline-sid-scoped-lease C: read from the
483
+ // canonical sid-scoped lease projection. The legacy
484
+ // unlink-on-session-mismatch purge is removed: the lease is
485
+ // keyed off the bound session id, so the mismatch path cannot
486
+ // fire (a lease under session A is never returned when the bound
487
+ // session is B).
488
+ const presence = readSkillPresenceFromLease(projectRootOverride);
489
+ if (presence === null)
488
490
  return null;
489
- const { presence, path: presencePath } = result;
490
- if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
491
- const currentSessionId = getCurrentSessionId(projectRootOverride);
492
- if (currentSessionId && presence.sessionId !== currentSessionId) {
493
- try {
494
- unlinkSync(presencePath);
495
- }
496
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
497
- // best effort
498
- }
499
- return null;
500
- }
501
- }
491
+ // Slice 4.0.11 statusline-sid-scoped-lease A: the legacy write to
492
+ // the single-slot marker file was removed. Heartbeat refresh now
493
+ // lives exclusively in `presence-lease-service.setPresenceLease`
494
+ // (the canonical sid-scoped lease). This function remains as a
495
+ // read-only back-compat shim for the 4-B / 4-C sub-slices; callers
496
+ // still receive the in-memory `presence` (with the new
497
+ // `lastHeartbeat` stamped) but the legacy `active-skill.json` file
498
+ // is no longer touched. The canonical lease / presence-index
499
+ // entries are the source of truth for heartbeat freshness.
502
500
  presence.lastHeartbeat = new Date().toISOString();
503
- writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
504
501
  return presence;
505
502
  }
506
503
  export function clearSkillPresence(projectRootOverride) {
@@ -518,12 +515,25 @@ export function clearSkillPresence(projectRootOverride) {
518
515
  // `.peaks/.active-skill.json` so a stale marker from a prior CLI
519
516
  // version cannot resurrect after a fresh `clear`. The canonical
520
517
  // lease + index entries are NOT touched by this shim — they
521
- // remain under the workflow's terminalize lock until
522
- // `terminalizeWorkflow` or the next workflow init reclaims them.
523
- const presencePath = resolvePresencePath(projectRootOverride);
524
- const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
518
+ // Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
519
+ // single-slot presence files (`.peaks/_runtime/active-skill.json`
520
+ // and `.peaks/.active-skill.json`) are removed. The canonical
521
+ // sid-scoped lease projection (under
522
+ // `.peaks/_runtime/<sid>/leases/`) is untouched here — workflow
523
+ // leases route through `terminalizeWorkflow`, NOT this shim.
524
+ //
525
+ // This shim remains as a best-effort stale-file cleanup for
526
+ // projects that still carry the deprecated single-slot file from
527
+ // a pre-4.0.11 CLI version. After one minor release the legacy
528
+ // paths are gone and the loop is a no-op. Returns true when a
529
+ // stale file was actually removed.
530
+ const projectRoot = resolveProjectRoot(projectRootOverride);
531
+ const candidates = [
532
+ resolve(projectRoot, '.peaks', '_runtime', 'active-skill.json'),
533
+ resolve(projectRoot, '.peaks', '.active-skill.json')
534
+ ];
525
535
  let cleared = false;
526
- for (const p of [presencePath, legacyPath]) {
536
+ for (const p of candidates) {
527
537
  if (!existsSync(p))
528
538
  continue;
529
539
  try {
@@ -1,28 +1,34 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
1
  import { resolve } from 'node:path';
3
2
  import { findProjectRoot } from '../config/config-safety.js';
4
3
  import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
5
4
  import { getSessionIdCanonical } from '../session/session-manager.js';
6
5
  import { resolveActiveSkillForCaller } from '../audit/enforcers/active-skill-resolver.js';
6
+ import { listPresenceLeases } from './presence-lease-service.js';
7
7
  import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
8
8
  /**
9
9
  * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
10
10
  *
11
11
  * Claude Code invokes the configured statusLine command on every turn and pipes
12
- * a JSON session payload on stdin. This renderer reads the durable presence file
13
- * (`.peaks/_runtime/active-skill.json`, with a one-minor-release back-compat
14
- * fallback to `.peaks/.active-skill.json`) and prints a single line that
15
- * Claude Code paints at the bottom of the terminal. Because it is rendered
16
- * by the harness — not emitted as LLM tokens — the signal cannot be forgotten
17
- * by the model, cannot be confused with normal output, and survives context
18
- * compaction.
12
+ * a JSON session payload on stdin. This renderer reads the canonical
13
+ * sid-scoped lease projection
14
+ * (`.peaks/_runtime/<sid>/leases/presence-<caller>-<workflow>.json` +
15
+ * the per-caller index under `presence-index/<caller>.json`) and prints a
16
+ * single line that Claude Code paints at the bottom of the terminal. Because
17
+ * it is rendered by the harness — not emitted as LLM tokens — the signal
18
+ * cannot be forgotten by the model, cannot be confused with normal output,
19
+ * and survives context compaction.
19
20
  *
20
21
  * This module is intentionally READ-ONLY. Unlike getSkillPresence in
21
22
  * skill-presence-service.ts, it never deletes or rewrites the presence file:
22
23
  * the statusLine runs on every turn and must have zero side effects.
24
+ *
25
+ * Slice 2026-08-05-statusline-sid-scoped-lease-B: the read no longer falls
26
+ * back to the project-level `.peaks/_runtime/active-skill.json` (or its
27
+ * legacy `.peaks/.active-skill.json`). The canonical lease projection is
28
+ * the only source. When `callerId === null` (non-IDE caller), the read
29
+ * picks the most recent in-flight lease across all callers; this is the
30
+ * documented back-compat path.
23
31
  */
24
- const PRESENCE_FILE = '.peaks/_runtime/active-skill.json';
25
- const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
26
32
  const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
27
33
  function resolveCwdFromStdin(stdin) {
28
34
  const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
@@ -96,6 +102,8 @@ function readActiveLeaf(projectRoot, sessionId) {
96
102
  'never-started',
97
103
  'unreadable',
98
104
  'stale',
105
+ 'queued', // Slice 2026-08-05 fix: stale dispatch entries stuck at 'queued' should
106
+ // not pollute statusline as in-flight leaves.
99
107
  ]);
100
108
  const inFlight = Object.values(index).filter((e) => !terminalStatuses.has(e.status));
101
109
  if (inFlight.length === 0)
@@ -111,16 +119,23 @@ function readActiveLeaf(projectRoot, sessionId) {
111
119
  * Read the presence file without any side effects. Returns null when the file is
112
120
  * absent (idle) and a sentinel object for malformed content (invalid-presence).
113
121
  *
114
- * When `callerId` is non-null, delegates to the canonical lease resolver
115
- * (presence-lease-service via active-skill-resolver) so the read is
116
- * session+caller-isolated. When `callerId` is null, falls back to the
117
- * project-level single-file read (back-compat for callers that don't pass
118
- * a callerId yet, e.g. legacy CLI invocations).
122
+ * Both branches now route through the canonical sid-scoped lease projection
123
+ * (slice 2026-08-05-statusline-sid-scoped-lease-B):
124
+ * - `callerId !== null` → `resolveActiveSkillForCaller` with the canonical
125
+ * (non-legacy) lease projection, filtered to this callerId.
126
+ * - `callerId === null` → enumerate `listPresenceLeases` for the
127
+ * canonical session and pick the most recent in-flight lease. Back-compat
128
+ * for non-IDE callers (e.g. legacy CLI invocations) that have no callerId.
129
+ *
130
+ * No fallback to `.peaks/_runtime/active-skill.json` (or its legacy path):
131
+ * the canonical lease projection is the single source of truth. When no
132
+ * in-flight leases exist, the read returns `{ presence: null, invalid: false }`
133
+ * and the renderer falls back to the idle state.
119
134
  */
120
135
  function readPresenceReadOnly(projectRoot, callerId) {
121
136
  if (callerId !== null) {
122
137
  try {
123
- const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
138
+ const resolution = resolveActiveSkillForCaller(projectRoot, { callerId });
124
139
  if (resolution.source === 'none' || resolution.skill === null) {
125
140
  return { presence: null, invalid: false };
126
141
  }
@@ -136,36 +151,55 @@ function readPresenceReadOnly(projectRoot, callerId) {
136
151
  return { presence: null, invalid: true };
137
152
  }
138
153
  }
139
- const presencePath = resolve(projectRoot, PRESENCE_FILE);
140
- // Back-compat: prefer the new canonical path; fall back to the legacy
141
- // `.peaks/.active-skill.json` for one minor release.
142
- const pathToRead = existsSync(presencePath) ? presencePath : resolve(projectRoot, PRESENCE_FILE_LEGACY);
143
- if (!existsSync(pathToRead)) {
154
+ // callerId === null branch: enumerate the canonical session dir's leases
155
+ // and pick the most recent in-flight lease. This is the back-compat path
156
+ // for non-IDE callers that don't supply a callerId.
157
+ let sessionId = null;
158
+ try {
159
+ sessionId = getSessionIdCanonical(projectRoot);
160
+ }
161
+ catch {
162
+ return { presence: null, invalid: false };
163
+ }
164
+ if (sessionId === null) {
144
165
  return { presence: null, invalid: false };
145
166
  }
167
+ let leases = [];
146
168
  try {
147
- const parsed = JSON.parse(readFileSync(pathToRead, 'utf8'));
148
- if (!parsed || typeof parsed !== 'object') {
149
- return { presence: null, invalid: true };
150
- }
151
- const candidate = parsed;
152
- if (typeof candidate.skill !== 'string' || candidate.skill.length === 0) {
153
- return { presence: null, invalid: true };
154
- }
155
- return {
156
- presence: {
157
- skill: candidate.skill,
158
- ...(typeof candidate.mode === 'string' ? { mode: candidate.mode } : {}),
159
- ...(typeof candidate.gate === 'string' ? { gate: candidate.gate } : {}),
160
- ...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {}),
161
- ...(typeof candidate.claudeSessionId === 'string' ? { claudeSessionId: candidate.claudeSessionId } : {})
162
- },
163
- invalid: false
164
- };
169
+ leases = listPresenceLeases(projectRoot, sessionId);
165
170
  }
166
171
  catch {
167
172
  return { presence: null, invalid: true };
168
173
  }
174
+ const inFlight = leases
175
+ .filter((l) => l.status === 'preparing' || l.status === 'running')
176
+ .filter((l) => typeof l.skill === 'string' && l.skill.length > 0);
177
+ if (inFlight.length === 0) {
178
+ return { presence: null, invalid: false };
179
+ }
180
+ // Most recent wins — sort by `lastHeartbeat` desc, fall back to `startedAt`.
181
+ const sorted = inFlight.slice().sort((a, b) => {
182
+ const hb = b.lastHeartbeat.localeCompare(a.lastHeartbeat);
183
+ if (hb !== 0)
184
+ return hb;
185
+ return b.startedAt.localeCompare(a.startedAt);
186
+ });
187
+ const latest = sorted[0];
188
+ if (latest === undefined || typeof latest.skill !== 'string' || latest.skill.length === 0) {
189
+ return { presence: null, invalid: false };
190
+ }
191
+ // Leases on disk may carry an optional `mode` field (the lease constructor
192
+ // spreads `input.mode` when present); the typed `SkillPresenceLease` does
193
+ // not declare it, so widen the read shape here.
194
+ const latestMode = latest.mode;
195
+ return {
196
+ presence: {
197
+ skill: latest.skill,
198
+ ...(typeof latestMode === 'string' && latestMode.length > 0 ? { mode: latestMode } : {}),
199
+ setAt: latest.startedAt,
200
+ },
201
+ invalid: false,
202
+ };
169
203
  }
170
204
  export function buildStatusLineModel(stdin, nowMs) {
171
205
  const cwd = resolveCwdFromStdin(stdin);
@@ -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.10",
3
+ "version": "4.0.12",
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",
101
- "peaks-loop-shared": "0.0.41",
102
- "peaks-loop-shared-channel": "0.0.19",
103
- "peaks-loop-mut": "0.1.15"
103
+ "peaks-loop-mut": "0.1.16",
104
+ "peaks-loop-shared-channel": "0.0.20",
105
+ "peaks-loop-shared": "0.0.43"
104
106
  },
105
107
  "peerDependencies": {
106
108
  "@alibaba-group/open-code-review": "1.3.1"