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.
- package/CHANGELOG.md +57 -0
- package/config/eslint/.peaks-rules.cjs +123 -0
- package/dist/cli/commands/code-commands.js +2 -0
- package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
- package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
- package/dist/cli/commands/workspace/reconcile-command.js +5 -2
- 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.js +4 -1
- package/dist/services/code/orchestrator-can-do.d.ts +114 -0
- package/dist/services/code/orchestrator-can-do.js +205 -0
- package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
- package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
- package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
- package/dist/services/doctor/doctor-service/types.d.ts +9 -0
- package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
- package/dist/services/hooks/presence-marker-detector.js +45 -85
- package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
- 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/sc/sc-service.d.ts +11 -7
- package/dist/services/sc/sc-service.js +29 -26
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/skills/hooks-settings-service.d.ts +1 -1
- package/dist/services/skills/hooks-settings-service.js +1 -1
- package/dist/services/skills/presence-lease-service.js +1 -0
- package/dist/services/skills/skill-presence-service.d.ts +0 -1
- package/dist/services/skills/skill-presence-service.js +112 -102
- package/dist/services/skills/skill-statusline-service.js +73 -39
- package/docs/test-style-contract.md +135 -0
- package/package.json +6 -4
- 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
- package/skills/peaks-code/SKILL.md +6 -0
- 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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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 =
|
|
177
|
+
const result = readSkillPresenceFromLease(projectRootOverride);
|
|
164
178
|
if (result === null)
|
|
165
179
|
return undefined;
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* by the
|
|
18
|
-
*
|
|
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
|
-
*
|
|
115
|
-
* (
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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, {
|
|
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
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
102
|
-
"peaks-loop-shared-channel": "0.0.
|
|
103
|
-
"peaks-loop-
|
|
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"
|