peaks-loop 4.0.41 → 4.0.43

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 (52) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +4 -0
  5. package/dist/cli/commands/api-diff-commands.d.ts +16 -0
  6. package/dist/cli/commands/api-diff-commands.js +55 -0
  7. package/dist/cli/commands/audit-commands.d.ts +16 -3
  8. package/dist/cli/commands/audit-commands.js +84 -31
  9. package/dist/cli/commands/job-commands.js +4 -2
  10. package/dist/cli/commands/scan-commands.js +1 -1
  11. package/dist/cli/commands/test-commands.d.ts +60 -3
  12. package/dist/cli/commands/test-commands.js +125 -7
  13. package/dist/services/audit/audit-goal-service.js +38 -3
  14. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
  15. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
  16. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  17. package/dist/services/doctor/doctor-service/types.d.ts +20 -0
  18. package/dist/services/hooks/write-gate.js +32 -9
  19. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  20. package/dist/services/llm/anthropic-runner.js +171 -0
  21. package/dist/services/llm/stub-runner.d.ts +11 -0
  22. package/dist/services/llm/stub-runner.js +33 -0
  23. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  24. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  25. package/dist/services/scan/api-diff-openapi.js +359 -0
  26. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  27. package/dist/services/scan/api-diff-recorded.js +577 -0
  28. package/dist/services/scan/api-diff-service.d.ts +34 -0
  29. package/dist/services/scan/api-diff-service.js +407 -0
  30. package/dist/services/scan/api-diff-types.d.ts +116 -0
  31. package/dist/services/scan/api-diff-types.js +46 -0
  32. package/dist/services/scan/archetype-service.js +27 -1
  33. package/dist/services/scan/existing-system-service.js +17 -4
  34. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  35. package/dist/services/scan/hook-convention-service.js +562 -0
  36. package/dist/services/scan/scan-types.d.ts +47 -0
  37. package/dist/services/session/caller-binding-service.d.ts +28 -0
  38. package/dist/services/session/caller-binding-service.js +10 -2
  39. package/dist/services/session/caller-id-types.d.ts +12 -2
  40. package/dist/services/session/index.d.ts +2 -2
  41. package/dist/services/session/index.js +2 -2
  42. package/dist/services/session/session-binding-bridge.js +11 -6
  43. package/dist/services/session/session-manager.d.ts +33 -1
  44. package/dist/services/session/session-manager.js +84 -25
  45. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  46. package/dist/services/skills/skill-presence-service.js +23 -3
  47. package/package.json +5 -5
  48. package/skills/bee/peaks-rd/SKILL.md +11 -3
  49. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  50. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  51. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  52. package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
@@ -96,6 +96,15 @@ export function getCallerBinding(projectRoot, callerId) {
96
96
  return null;
97
97
  }
98
98
  }
99
+ export function resolveCallerBinding(projectRoot, callerId) {
100
+ const binding = getCallerBinding(projectRoot, callerId);
101
+ if (binding === null)
102
+ return { status: 'absent' };
103
+ if (!existsSync(getSessionDir(projectRoot, binding.peakSessionId))) {
104
+ return { status: 'stale', binding };
105
+ }
106
+ return { status: 'bound', binding };
107
+ }
99
108
  /**
100
109
  * Write or update a per-caller binding file. The caller is responsible
101
110
  * for the binding object (callerId must match the file stem, peakSessionId
@@ -149,8 +158,7 @@ export function updateCallerBindingSessionId(projectRoot, callerId, peakSessionI
149
158
  return false;
150
159
  setCallerBinding(projectRoot, callerId, {
151
160
  ...existing,
152
- peakSessionId,
153
- lastActivityAt: new Date().toISOString()
161
+ peakSessionId
154
162
  });
155
163
  return true;
156
164
  }
@@ -58,6 +58,18 @@ export interface CallerProjection {
58
58
  /**
59
59
  * On-disk shape of `.peaks/_runtime/callers/<callerId>.json`. One file
60
60
  * per caller; two callers may point to the same `peakSessionId` (D6).
61
+ *
62
+ * Slice 2026-09-12 (rid=caller-binding-staleness): the former
63
+ * `lastActivityAt` field is REMOVED. It promised "bumped on every
64
+ * `peaks <cmd>` that touches the binding" but was written only at first
65
+ * bind and at an explicit rebind — never on reuse — so no freshness
66
+ * decision could rest on it without first fixing the write side, and a
67
+ * TTL built on a timestamp that ages on a live binding would un-bind
68
+ * exactly the live sessions the caller-first resolution exists to keep
69
+ * apart. Freshness is decided by the bound session directory (see
70
+ * `resolveCallerBinding`) plus rotation clearing the binding, not by a
71
+ * clock. A legacy file that still carries the key is read fine — the
72
+ * extra property is ignored.
61
73
  */
62
74
  export interface CallerBinding {
63
75
  /** Echo of the filename stem; matches D1 regex. */
@@ -68,8 +80,6 @@ export interface CallerBinding {
68
80
  projectRoot: string;
69
81
  /** ISO 8601 timestamp; stamped at first write. */
70
82
  createdAt: string;
71
- /** ISO 8601 timestamp; bumped on every `peaks <cmd>` that touches the binding. */
72
- lastActivityAt: string;
73
83
  /** Last skill that touched this binding, e.g. "peaks-code". */
74
84
  skill: string;
75
85
  /** Last mode, e.g. "full-auto". */
@@ -1,7 +1,7 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
1
+ export { ensureSession, getSessionId, resolveCallerBoundSession, type CallerBoundSession, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
3
  export { resolveCallerId, } from './resolve-caller-id.js';
4
- export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
4
+ export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, resolveCallerBinding, type CallerBindingResolution, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
5
5
  export { PLATFORM_FALLBACKS, type PlatformFallback } from './platform-fallbacks.js';
6
6
  export { resolveCallerProjection, type ResolveCallerIdOptions } from './resolve-caller-id.js';
7
7
  export { type CallerProjection, type CallerProjectionSource, type CallerResolveErrorCode } from './caller-id-types.js';
@@ -1,8 +1,8 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
1
+ export { ensureSession, getSessionId, resolveCallerBoundSession, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
3
  // Slice 020 — caller-keyed session binding. The new canonical path.
4
4
  export { resolveCallerId, } from './resolve-caller-id.js';
5
- export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
5
+ export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, resolveCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
6
6
  // Slice 4.0.8 (C1): PLATFORM_FALLBACKS is deleted. Re-export kept as
7
7
  // a deprecated alias for one minor release so legacy consumers keep
8
8
  // type-checking; the array is empty and unused at runtime. New code
@@ -23,7 +23,7 @@ import { dirname, join } from 'node:path';
23
23
  import { randomBytes } from 'node:crypto';
24
24
  import { initWorkspace } from '../workspace/workspace-service.js';
25
25
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
26
- import { getCallerBinding, setCallerBinding, } from './caller-binding-service.js';
26
+ import { resolveCallerBinding, setCallerBinding, } from './caller-binding-service.js';
27
27
  import { resolveCallerProjection } from './resolve-caller-id.js';
28
28
  import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding, setSessionMeta } from './session-manager.js';
29
29
  // --- Lower-level helpers the bridge needs (moved verbatim) ---
@@ -205,6 +205,12 @@ export function _resetLastResolvedOuterForTest() {
205
205
  * `ensureSession` consults it BEFORE `readSessionFile` so a binding
206
206
  * written by a previous call from the same caller is preferred over
207
207
  * a stale `session.json` (the legacy single-file binding).
208
+ *
209
+ * Slice 2026-09-12 (rid=caller-binding-staleness): the binding is only
210
+ * usable when its bound session directory still exists
211
+ * (`resolveCallerBinding`). A binding pointing at a deleted session
212
+ * directory is treated as absent, so `ensureSession` falls through and
213
+ * rolls a fresh session instead of returning an id whose tree is gone.
208
214
  */
209
215
  function resolveCallerBindingForEnsure(projectRoot) {
210
216
  let projection;
@@ -215,12 +221,12 @@ function resolveCallerBindingForEnsure(projectRoot) {
215
221
  return null;
216
222
  }
217
223
  try {
218
- const binding = getCallerBinding(projectRoot, projection.callerId);
219
- if (binding === null)
224
+ const resolution = resolveCallerBinding(projectRoot, projection.callerId);
225
+ if (resolution.status !== 'bound')
220
226
  return null;
221
227
  return {
222
- sessionId: binding.peakSessionId,
223
- createdAt: binding.createdAt,
228
+ sessionId: resolution.binding.peakSessionId,
229
+ createdAt: resolution.binding.createdAt,
224
230
  callerId: projection.callerId
225
231
  };
226
232
  }
@@ -324,7 +330,6 @@ export async function ensureSession(projectRoot) {
324
330
  peakSessionId: sessionId,
325
331
  projectRoot,
326
332
  createdAt: now,
327
- lastActivityAt: now,
328
333
  skill: 'peaks-code',
329
334
  mode: 'unknown',
330
335
  gate: 'startup'
@@ -39,7 +39,10 @@ export type SessionMeta = {
39
39
  * is left intact — rotating does NOT delete the user's data, it
40
40
  * just unbinds the project from that session. Also drops the legacy
41
41
  * `.peaks/.session.json` if present so a stale read from another
42
- * tool cannot re-bind the project after rotation.
42
+ * tool cannot re-bind the project after rotation. Also drops the
43
+ * ROTATING caller's per-caller binding when it points at the rotated-out
44
+ * session (slice 2026-09-12, rid=caller-binding-staleness) — see
45
+ * `clearRotatedCallerBinding`.
43
46
  *
44
47
  * Returns the id of the session that was unbound, or `null` if
45
48
  * no binding was present. The caller is expected to do something
@@ -126,6 +129,35 @@ export declare function getSessionId(projectRoot: string): string | null;
126
129
  * so this variant is opt-in.
127
130
  */
128
131
  export declare function getSessionIdCanonical(projectRoot: string): string | null;
132
+ /**
133
+ * Outcome of the caller-binding half of session resolution.
134
+ *
135
+ * `staleSessionId` is the id of a per-caller binding that was SKIPPED
136
+ * because its session directory no longer exists — `null` for the
137
+ * ordinary "no binding" case. It exists so the fall-through is
138
+ * observable: a caller can tell "this caller has no binding" apart from
139
+ * "this caller's binding pointed at a session tree that is gone", and
140
+ * report it instead of silently resolving a different session.
141
+ */
142
+ export type CallerBoundSession = {
143
+ sessionId: string | null;
144
+ staleSessionId: string | null;
145
+ };
146
+ /**
147
+ * Resolve the caller-binding primary source (slice 2026-09-12,
148
+ * rid=caller-binding-staleness).
149
+ *
150
+ * The caller-first precedence is unchanged: this caller's binding still
151
+ * outranks the project-global `session.json`. What changed is what counts
152
+ * as a USABLE binding — a binding whose bound session directory is gone
153
+ * is stale, is not trusted, and is reported through `staleSessionId`
154
+ * instead of being returned as if it were live.
155
+ *
156
+ * Returns `{ sessionId: null, staleSessionId: null }` when caller-id
157
+ * resolution fails (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file
158
+ * exists, or when the file is malformed.
159
+ */
160
+ export declare function resolveCallerBoundSession(projectRoot: string): CallerBoundSession;
129
161
  /**
130
162
  * Get the absolute path to the current session directory.
131
163
  * Creates the session if it doesn't exist.
@@ -11,7 +11,7 @@ import { dirname, join, resolve } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
13
13
  import { ensureSession } from './session-binding-bridge.js';
14
- import { getCallerBinding } from './caller-binding-service.js';
14
+ import { getCallerBinding, getCallerBindingFile, resolveCallerBinding } from './caller-binding-service.js';
15
15
  import { resolveCallerProjection } from './resolve-caller-id.js';
16
16
  // As of slice 2026-06-05-peaks-runtime-layer the project-level session
17
17
  // binding lives under `.peaks/_runtime/session.json`. The legacy
@@ -199,7 +199,10 @@ function writeSessionFile(projectRoot, info) {
199
199
  * is left intact — rotating does NOT delete the user's data, it
200
200
  * just unbinds the project from that session. Also drops the legacy
201
201
  * `.peaks/.session.json` if present so a stale read from another
202
- * tool cannot re-bind the project after rotation.
202
+ * tool cannot re-bind the project after rotation. Also drops the
203
+ * ROTATING caller's per-caller binding when it points at the rotated-out
204
+ * session (slice 2026-09-12, rid=caller-binding-staleness) — see
205
+ * `clearRotatedCallerBinding`.
203
206
  *
204
207
  * Returns the id of the session that was unbound, or `null` if
205
208
  * no binding was present. The caller is expected to do something
@@ -231,8 +234,49 @@ export function rotateSessionBinding(projectRoot) {
231
234
  // best-effort: a stale legacy binding is not blocking
232
235
  }
233
236
  }
237
+ // Slice 2026-09-12 (rid=caller-binding-staleness): the per-caller
238
+ // binding is the FIRST thing `getSessionId` reads, so unlinking only
239
+ // the project-global files left the rotating caller still resolving
240
+ // the session it just rotated out of — `peaks job init` re-created
241
+ // `.peaks/_runtime/<old-sid>/job/...` instead of reporting
242
+ // NO_ACTIVE_SESSION, and `ensureSession` returned the old id instead
243
+ // of rolling a fresh session. The rotation is only complete once THIS
244
+ // caller's binding is gone too (the next resolution then either
245
+ // falls through to the global file or starts a fresh session).
246
+ clearRotatedCallerBinding(projectRoot, previous.sessionId);
234
247
  return previous.sessionId;
235
248
  }
249
+ /**
250
+ * Drop the ROTATING caller's per-caller binding when it points at the
251
+ * session being rotated out.
252
+ *
253
+ * Only that caller is affected: every other caller holds its own session
254
+ * on purpose (D6 multi-caller isolation — a second IDE window must not
255
+ * lose its binding because a sibling window rotated). When no caller id
256
+ * is resolvable (CI / plain shell), there is no per-caller binding to
257
+ * clear and this is a no-op.
258
+ */
259
+ function clearRotatedCallerBinding(projectRoot, rotatedOutSessionId) {
260
+ let callerId;
261
+ try {
262
+ callerId = resolveCallerProjection({ projectRoot, env: process.env }).callerId;
263
+ }
264
+ catch { // PEAKS_CALLER_NOT_RESOLVED → no per-caller binding exists
265
+ return;
266
+ }
267
+ const binding = getCallerBinding(projectRoot, callerId);
268
+ if (binding === null || binding.peakSessionId !== rotatedOutSessionId) {
269
+ return;
270
+ }
271
+ try {
272
+ unlinkSync(getCallerBindingFile(projectRoot, callerId));
273
+ }
274
+ catch { // TODO(g2): best-effort unlink — rotation must not fail on a cleanup
275
+ // error (Windows EPERM). On failure the binding survives; it is stale
276
+ // for as long as its session directory is gone, and the next rotation
277
+ // or rebind retries the clear.
278
+ }
279
+ }
236
280
  /**
237
281
  * Bind the project's current session to the given session id by writing
238
282
  * `.peaks/.session.json`. The single-session binding is the source of truth
@@ -401,17 +445,19 @@ export { ensureSession, ensureSessionWithRotation } from './session-binding-brid
401
445
  */
402
446
  export function getSessionId(projectRoot) {
403
447
  // Slice 2026-08-06-session-cacde8-A.5a: caller-binding becomes
404
- // primary source. Read order is (1) `getCallerBinding` if
405
- // `resolveCallerProjection` succeeds, (2) `readSessionFile`
406
- // (the legacy session.json), (3) null (no binding). The legacy
407
- // session.json is preserved as a fallback so a project without
408
- // a caller-id resolution still resolves its binding (e.g. CLI
409
- // run from a stock shell with no IDE adapter). `PEAKS_CALLER_NOT_RESOLVED`
410
- // falls through to the session.json path; no caller-facing exit
411
- // change.
412
- const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
413
- if (fromCallerBinding !== null)
414
- return fromCallerBinding;
448
+ // primary source. Read order is (1) the per-caller binding if
449
+ // `resolveCallerProjection` succeeds — and only when that binding is
450
+ // still usable (`resolveCallerBoundSession` drops one whose session
451
+ // directory is gone, slice 2026-09-12 rid=caller-binding-staleness),
452
+ // (2) `readSessionFile` (the legacy session.json), (3) null (no
453
+ // binding). The legacy session.json is preserved as a fallback so a
454
+ // project without a caller-id resolution still resolves its binding
455
+ // (e.g. CLI run from a stock shell with no IDE adapter).
456
+ // `PEAKS_CALLER_NOT_RESOLVED` falls through to the session.json path;
457
+ // no caller-facing exit change.
458
+ const { sessionId } = resolveCallerBoundSession(projectRoot);
459
+ if (sessionId !== null)
460
+ return sessionId;
415
461
  const info = readSessionFile(projectRoot);
416
462
  return info?.sessionId ?? null;
417
463
  }
@@ -446,27 +492,40 @@ export function getSessionIdCanonical(projectRoot) {
446
492
  // Slice 2026-08-06-session-cacde8-A.5a: same caller-binding primary
447
493
  // lookup as `getSessionId`; fall back to the canonical-fallback
448
494
  // `readSessionFileCanonical` if caller-binding is absent / unresolved.
449
- const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
450
- if (fromCallerBinding !== null)
451
- return fromCallerBinding;
495
+ const { sessionId } = resolveCallerBoundSession(projectRoot);
496
+ if (sessionId !== null)
497
+ return sessionId;
452
498
  const info = readSessionFileCanonical(projectRoot);
453
499
  return info?.sessionId ?? null;
454
500
  }
455
501
  /**
456
- * Internal helper: read the caller-binding primary source.
457
- * Returns `null` when caller-id resolution fails
458
- * (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file exists,
459
- * or when the file is malformed. NOT exported; the public surface
460
- * is still `getSessionId` / `getSessionIdCanonical`.
502
+ * Resolve the caller-binding primary source (slice 2026-09-12,
503
+ * rid=caller-binding-staleness).
504
+ *
505
+ * The caller-first precedence is unchanged: this caller's binding still
506
+ * outranks the project-global `session.json`. What changed is what counts
507
+ * as a USABLE binding — a binding whose bound session directory is gone
508
+ * is stale, is not trusted, and is reported through `staleSessionId`
509
+ * instead of being returned as if it were live.
510
+ *
511
+ * Returns `{ sessionId: null, staleSessionId: null }` when caller-id
512
+ * resolution fails (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file
513
+ * exists, or when the file is malformed.
461
514
  */
462
- function getSessionIdFromCallerBinding(projectRoot) {
515
+ export function resolveCallerBoundSession(projectRoot) {
463
516
  try {
464
517
  const projection = resolveCallerProjection({ projectRoot, env: process.env });
465
- const binding = getCallerBinding(projectRoot, projection.callerId);
466
- return binding?.peakSessionId ?? null;
518
+ const resolution = resolveCallerBinding(projectRoot, projection.callerId);
519
+ if (resolution.status === 'bound') {
520
+ return { sessionId: resolution.binding.peakSessionId, staleSessionId: null };
521
+ }
522
+ if (resolution.status === 'stale') {
523
+ return { sessionId: null, staleSessionId: resolution.binding.peakSessionId };
524
+ }
525
+ return { sessionId: null, staleSessionId: null };
467
526
  }
468
527
  catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
469
- return null;
528
+ return { sessionId: null, staleSessionId: null };
470
529
  }
471
530
  }
472
531
  /**
@@ -52,9 +52,23 @@ export type SkillPresence = {
52
52
  lastHeartbeat?: string;
53
53
  };
54
54
  /**
55
- * Resolve the active peaks session id from
56
- * `.peaks/_runtime/session.json` (legacy: `.peaks/.session.json`).
57
- * Returns `null` when no session is bound.
55
+ * Resolve the active peaks session id.
56
+ *
57
+ * Resolution order (slice caller-first-session-resolution):
58
+ * 1. The per-caller binding (via `getSessionId`) — the SAME source
59
+ * `peaks session info --active` resolves through.
60
+ * 2. The project-global `.peaks/_runtime/session.json` (legacy:
61
+ * `.peaks/.session.json`) — unchanged back-compat fallback.
62
+ * 3. `null` when neither is present.
63
+ *
64
+ * The caller-first step is what keeps two IDE windows on one project
65
+ * apart. The project-global file is last-writer-wins, so the window
66
+ * that initialised most recently owned it for every reader; commands
67
+ * that resolved through this helper (job / dispatch / worktree /
68
+ * share / ...) then wrote into THAT window's session tree, silently
69
+ * cross-contaminating the two sessions.
70
+ *
71
+ * Returns `null` when no session is bound. Never throws.
58
72
  *
59
73
  * Public export: callers like `peaks sub-agent dispatch` use this
60
74
  * to auto-resolve `--session-id` when the LLM driver forgets to
@@ -81,9 +81,23 @@ function resolveProjectRoot(override) {
81
81
  return findProjectRoot(process.cwd()) ?? process.cwd();
82
82
  }
83
83
  /**
84
- * Resolve the active peaks session id from
85
- * `.peaks/_runtime/session.json` (legacy: `.peaks/.session.json`).
86
- * Returns `null` when no session is bound.
84
+ * Resolve the active peaks session id.
85
+ *
86
+ * Resolution order (slice caller-first-session-resolution):
87
+ * 1. The per-caller binding (via `getSessionId`) — the SAME source
88
+ * `peaks session info --active` resolves through.
89
+ * 2. The project-global `.peaks/_runtime/session.json` (legacy:
90
+ * `.peaks/.session.json`) — unchanged back-compat fallback.
91
+ * 3. `null` when neither is present.
92
+ *
93
+ * The caller-first step is what keeps two IDE windows on one project
94
+ * apart. The project-global file is last-writer-wins, so the window
95
+ * that initialised most recently owned it for every reader; commands
96
+ * that resolved through this helper (job / dispatch / worktree /
97
+ * share / ...) then wrote into THAT window's session tree, silently
98
+ * cross-contaminating the two sessions.
99
+ *
100
+ * Returns `null` when no session is bound. Never throws.
87
101
  *
88
102
  * Public export: callers like `peaks sub-agent dispatch` use this
89
103
  * to auto-resolve `--session-id` when the LLM driver forgets to
@@ -93,6 +107,12 @@ function resolveProjectRoot(override) {
93
107
  */
94
108
  export function getCurrentSessionId(projectRootOverride) {
95
109
  const projectRoot = resolveProjectRoot(projectRootOverride);
110
+ // Caller-first: a binding for THIS caller outranks the project-global
111
+ // file. Delegated to `getSessionId` rather than re-implemented, so the
112
+ // two answers to "which session is current?" cannot drift again.
113
+ const callerBound = getSessionId(projectRoot);
114
+ if (callerBound !== null)
115
+ return callerBound;
96
116
  const sessionPath = resolve(projectRoot, SESSION_FILE);
97
117
  const legacyPath = resolve(projectRoot, SESSION_FILE_LEGACY);
98
118
  // Back-compat window: prefer the new canonical path; fall back to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.41",
3
+ "version": "4.0.43",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -101,10 +101,10 @@
101
101
  "fzf": "^0.5.2",
102
102
  "yaml": "^2.9.0",
103
103
  "zod": "^4.4.3",
104
- "peaks-loop-internal-runtime": "0.0.26",
105
- "peaks-loop-shared-channel": "0.0.43",
106
- "peaks-loop-mut": "0.1.39",
107
- "peaks-loop-shared": "0.0.75"
104
+ "peaks-loop-mut": "0.1.41",
105
+ "peaks-loop-shared": "0.0.77",
106
+ "peaks-loop-internal-runtime": "0.0.28",
107
+ "peaks-loop-shared-channel": "0.0.45"
108
108
  },
109
109
  "devDependencies": {
110
110
  "@changesets/cli": "2.31.1",
@@ -223,11 +223,19 @@ When RD work creates a frontend application and the user has not specified a tec
223
223
 
224
224
  → see `references/frontend-project-generation.md` for the scaffold protocol.
225
225
 
226
- ## Frontend anti-corruption layer (ACL)
226
+ ## Frontend anti-corruption layer (ACL) — route by integration mode first
227
227
 
228
- When RD work touches the frontend (pure frontend or full-stack), enforce the ACL discipline: the frontend's internal model is never polluted by external / API shapes. DTO ↔ ViewModel mapping converts external fields to internal fields at the boundary, and all conversion logic lives in one mapper file per domain (e.g. `mappers/user.mapper.ts`) — never scattered across pages. Hard constraint, verified by QA / code-review.
228
+ When RD work touches the frontend, read `.integrationMode` from `## Project mode` in `.peaks/project-scan/project-scan.md` (written from `peaks scan archetype --json`) and run that mode's procedure. Do **not** route on the `frontendOnly` boolean: it is back-compat only and merges two modes that need different first commands (`frontendOnly=false` covers both `full-stack` and `prd-plus-interface-doc`; `frontendOnly=true` covers `prd-only`).
229
229
 
230
- → see `references/frontend-acl-mapper.md` for the three rules + reference shape + verification.
230
+ | `.integrationMode` | RD runs first | Contract source | Artifact that proves it ran |
231
+ |---|---|---|---|
232
+ | `full-stack` | read `## API` in `.peaks/project-scan/project-scan.md` (a real wrapper + endpoint inventory exist here) | the backend's own response type in this repo | the per-domain mapper at the path in `## API → DTO ↔ ViewModel boundary`, plus `peaks scan diff-vs-scope --rid <rid> --project <repo>` |
233
+ | `prd-plus-interface-doc` | `peaks scan api-diff <doc> --project <repo>`, **before writing any type** | the OpenAPI document | the doc-derived `src/services/types/<feature>-api.types.ts` + the `Exact` section recorded in `.peaks/_runtime/<sessionId>/rd/requests/<rid>.md` |
234
+ | `prd-only` | write `.peaks/_runtime/<sessionId>/rd/mock-plan.md` before any mock file | an authored guess | `mock-plan.md` + `mock/<feature>-mock.ts` |
235
+
236
+ Whichever the mode, the mapping rule is unchanged: the frontend's internal model is never polluted by external / API shapes, DTO ↔ ViewModel conversion happens at the boundary, and all conversion logic for a domain lives in exactly one mapper file (e.g. `mappers/user.mapper.ts`) — never scattered across pages. Hard constraint, verified by QA / code-review.
237
+
238
+ → see `references/frontend-acl-mapper.md` for the three rules + reference shape + verification, and `skills/peaks-code/references/frontend-only-mode.md` §"Integration-mode routing (RD)" for the numbered procedure per mode — including the two checks each mode **cannot** perform (doc↔server drift; and, for `prd-only`, that no mechanism detects a real document appearing, since the §2.6 staleness artifact is not built).
231
239
 
232
240
  ## Artifact and standards output
233
241
 
@@ -20,7 +20,8 @@ The CLI emits stable JSON containing:
20
20
  - `componentNaming`: `PascalCase | kebab-case | mixed | unknown` (decided from real file names under the component directory)
21
21
  - `componentDir`, `serviceDir`, `hookDir` — first matching path found
22
22
  - `samples[]` — up to 5 most-recently-modified files per kind
23
- - `inconsistencies[]` — token names that have different values across sources
23
+ - `hookConvention` — the hook directories' file CONTENTS read (not just paths): `{ directories[], inconsistencies[] }`. Each directory carries `namingPattern`, `dominantReturnShape` (null when no class is strictly dominant) + `dominantReturnSignature`, `offShapeHooks[]` (hooks whose shape CLASS differs — key drift is reported separately) plus the file-level `hookFileCount` and `mapperFiles[]`; each hook carries `{ file, name, usePrefix, returnShape, returnSignature }`. These are OBSERVED from the text, never type-checked: an import specifier matching /mapper/i is recorded against the FILE that contains it, and a file absent from `mapperFiles[]` is the absence of an observation, NOT evidence that its hooks map data inline
24
+ - `inconsistencies[]` — token names that have different values across sources, followed by the hook return-shape / hook-naming / mapper-delegation inconsistencies (all prefixed by the directory they belong to)
24
25
 
25
26
  Copy these fields VERBATIM into `existing-system.md`. Do not re-classify tokens; do not invent additional samples.
26
27
 
@@ -59,6 +60,9 @@ Use the template below. Every value must come from the CLI JSON; leave a section
59
60
  ## Hooks convention
60
61
  - Directory: <conventions.hookDir>
61
62
  - Sample files: <conventions.samples filtered by kind=hook>
63
+ - Observed return shape: <per directory: namingPattern + dominantReturnShape + dominantReturnSignature from conventions.hookConvention.directories[*]; "- (none)" when the directory holds no exported function>
64
+ - Hooks deviating from it: <offShapeHooks; "- (none)" when empty>
65
+ - Mapper delegation: <mapperFiles.length of hookFileCount per directory — observed from import paths only, at FILE granularity; do not read a missing import as inlined mapping>
62
66
 
63
67
  ## Detected inconsistencies
64
68
  <paste inconsistencies[*] verbatim; if empty, write "- (none)">
@@ -1,23 +1,65 @@
1
1
  # Peaks-Loop Frontend-only development mode
2
2
 
3
3
  > Extracted from `skills/peaks-code/SKILL.md` on 2026-06-09 (slice 019 — slim skill files to references) to keep SKILL.md under the 800-line cap from `common/coding-style.md`. The content below is the verbatim Frontend-only development mode section that was previously inline; nothing was paraphrased, just relocated.
4
+ >
5
+ > **2026-09-12 (`rd-routing-by-integration-mode`):** this file is now the routing home for all three integration modes, not just `prd-only`. `### Integration-mode routing (RD)` was added and the boolean-era text in `### Mode determination` was retargeted onto `.integrationMode`; the mock table and placeholder layout below belong to `prd-only`.
4
6
 
5
- When the project has no live backend (no swagger.json, no API server), Code must activate frontend-only mode.
7
+ `peaks scan archetype --json` classifies every project into exactly one of three integration modes (`.integrationMode`). Code and RD both branch on that value — the `frontendOnly` boolean is retained for back-compat only (the swarm plan still reads it) and is no longer a routing decision.
6
8
 
7
9
  ### Mode determination (deterministic — CLI is the source of truth)
8
10
 
9
- The CLI decision is authoritative. Read `frontendOnly` and `frontendOnlyReason` directly from the `peaks scan archetype --json` output and copy both into `project-scan.md` under `## Project mode`. Do NOT re-derive the decision from user phrasing.
11
+ Read `.integrationMode` and `.integrationModeReason` from the `peaks scan archetype --json` output and copy both into `.peaks/project-scan/project-scan.md` under `## Project mode`. Copy `frontendOnly` / `frontendOnlyReason` alongside them under `## Project mode`, labelled back-compat. Do NOT re-derive the decision from user phrasing — the three values are already deterministic:
12
+
13
+ | `.integrationMode` | `.integrationModeReason` | What is physically present |
14
+ |---|---|---|
15
+ | `full-stack` | `backend-detected` | a backend framework, Next API routes, or a backend dir in this repo |
16
+ | `prd-plus-interface-doc` | `interface-doc-present` | an OpenAPI / proto document, and no backend here |
17
+ | `prd-only` | `no-backend-no-interface-doc` | neither |
18
+
19
+ A 0-1 bootstrap stub writes `unknown`; treat that as "not yet scanned", not as a fourth mode.
20
+
21
+ Back-compat mapping: `frontendOnly=false` covers `full-stack` **and** `prd-plus-interface-doc`; `frontendOnly=true` covers `prd-only`. That is why the boolean cannot route work — it merges two modes that need different procedures.
10
22
 
11
23
  User-stated intent is **only** consulted when it conflicts with the CLI result. The two conflict cases:
12
24
 
13
- - **CLI says `frontendOnly=false` but the user says "前端项目 / 没有后端 / 先 mock 数据"**: STOP and `AskUserQuestion` to confirm whether to override the scan (the repo probably contains a backend folder the user wants to ignore). Record the override decision and reason in `project-scan.md`.
14
- - **CLI says `frontendOnly=true` but the user says "需要做后端 / 加 API"**: STOP and `AskUserQuestion` to confirm whether the request actually targets the missing backend (the user may be confused about repo scope, or there is a separate backend repo Code should switch to).
25
+ - **CLI says `full-stack` but the user says "前端项目 / 没有后端 / 先 mock 数据"**: STOP and `AskUserQuestion` to confirm whether to override the scan (the repo probably contains a backend folder the user wants to ignore). Record the override decision and reason in `.peaks/project-scan/project-scan.md` under `## Project mode`.
26
+ - **CLI says `prd-only` but the user says "需要做后端 / 加 API"**: STOP and `AskUserQuestion` to confirm whether the request actually targets the missing backend (the user may be confused about repo scope, or there is a separate backend repo Code should switch to).
15
27
 
16
28
  When there is no conflict, do not ask — the CLI value wins and the workflow proceeds.
17
29
 
30
+ ### Integration-mode routing (RD) — three procedures
31
+
32
+ RD reads `.integrationMode` from `## Project mode` in `.peaks/project-scan/project-scan.md` and follows exactly one of the procedures below. Each mode has a different first command, a different contract source, and a different artifact; running one mode's steps under another mode is the collapse this routing exists to prevent.
33
+
34
+ #### `full-stack` — the backend is in this repo
35
+
36
+ 1. **First:** read `## API` in `.peaks/project-scan/project-scan.md`. A real request wrapper, error shape, and endpoint inventory exist here — take them from the scan instead of re-deriving them by reading every service file.
37
+ 2. If `.detected.swaggerPaths` from `peaks scan archetype --json` is non-empty, run `peaks scan api-diff <that path> --project <repo>` **before** editing any `*-api.types.ts`, and start from its `Exact` section.
38
+ 3. **Boundary:** the DTO is the backend's own response type, imported at exactly one site per domain — the mapper path recorded under `## API → DTO ↔ ViewModel boundary` (`frontend-acl-mapper.md` rule 3: one mapper file per domain, reference shape `src/mappers/user.mapper.ts`). The internal ViewModel file it targets is the one recorded in that same `## API` row (reference shape `src/models/<domain>.ts`).
39
+ 4. **Produces:** that mapper file, and the `## Red-line scope` section of `.peaks/_runtime/<sessionId>/rd/requests/<rid>.md` naming the in-scope endpoints. Close with `peaks scan diff-vs-scope --rid <rid> --project <repo>` (Gate B8).
40
+ 5. **MUST NOT** mock. There is no missing contract to guess, so no `mock-plan.md` entry and no `mock/*.ts` file is written. This is not honour-system: an inline mock literal in a changed file under `src/` fails `rl-mock-placement-001` (`src/services/audit/enforcers/mock-placement.ts`).
41
+
42
+ #### `prd-plus-interface-doc` — a document exists, no backend here
43
+
44
+ 1. **First:** `peaks scan api-diff <doc> --project <repo>`, where `<doc>` is a path from `.detected.swaggerPaths`. Run it **before any type is written** — the whole point of this mode is that the recorded interfaces start from the document rather than from a guess. The `Exact` section is the change list; it is exact because both sides are parsed.
45
+ 2. Write or refresh `src/services/types/<feature>-api.types.ts` — one interface per endpoint the `Exact` section names. Every field must trace to a line of the document.
46
+ 3. **Boundary:** the mapper imports its DTO from the doc-derived `*-api.types.ts` and its ViewModel from the internal model file recorded under `## API → DTO ↔ ViewModel boundary`. The mapper is what absorbs a doc revision: after the document changes, re-run step 1 and the mapper file appears in the `Candidate mentions` section as the place to edit.
47
+ 4. **Produces:** the doc-derived `*-api.types.ts`, the mapper, and the `api-diff` `Exact` output pasted into `.peaks/_runtime/<sessionId>/rd/requests/<rid>.md`.
48
+ 5. **MUST NOT** author a DTO from memory — that is `prd-only`'s guess, and here it would silently shadow the document. **MUST NOT** treat the `Candidate mentions` section as exact: it is a name-grep over the consumer's source, so it over-reports test fixtures and under-reports i18n keys, AntD `columns` arrays, `data-field` attributes, and monorepo barrels.
49
+ 6. **Not checkable in this repo:** nothing detects doc↔server drift. `api-diff` compares the document against what the project recorded; if the document itself is stale, the interfaces compile clean and the UI renders `undefined`. `api-diff` prints this limitation in its own output footer — do not restate it as covered.
50
+
51
+ #### `prd-only` — no backend, no document
52
+
53
+ 1. **First:** pick a row of the mock-strategy table below from the project's data-fetching pattern in `## API`, then write `.peaks/_runtime/<sessionId>/rd/mock-plan.md` **before producing any mock file** — chosen strategy, planned file paths, one-line rationale per file (`mock-plan.md` is the source of truth for mock locations across runs; RD reads it before writing code and QA reads it before writing test cases). If the table's last row applies ("cannot decide from scan alone"), STOP and `AskUserQuestion`.
54
+ 2. Define the response interfaces first: `src/services/types/<feature>-api.types.ts`, per the placeholder layout below. These interfaces are an **authored guess** — mark them as such in the file header.
55
+ 3. **Boundary:** write the mapper against the *interface*, never against the mock module. That is what keeps the guess cheap: when the real document lands, the mock file is deleted and the mapper's import target changes — no component, hook, or store is touched.
56
+ 4. **Produces:** `mock-plan.md`, the `*-api.types.ts` interfaces, `mock/<feature>-mock.ts`, and the mapper.
57
+ 5. **MUST NOT** write mock data inline in a component file — mock files live in the mock directory, and an inline mock literal in a changed file under `src/` fails `rl-mock-placement-001`. Every mock file carries the `// MOCK: Replace with real API call when swagger.json is available` header (mock rule 4 below).
58
+ 6. **Exit path:** when a document appears, run `peaks scan api-diff <doc> --project <repo>` and follow §"Mock-to-real migration path" below. **Not checkable in this repo:** nothing detects "a real document now exists for this domain" automatically, and the conditional contract artifact (`.peaks/api-contract.json` with staleness detection, design spec §2.6 / S3) is **not built**. Until it is, step 1 of the migration is a run RD schedules manually.
59
+
18
60
  ### Mock data strategy selection
19
61
 
20
- Code records the chosen mock strategy in `.peaks/_runtime/<sessionId>/rd/tech-doc.md` under a `## Mock Data Strategy` section. The choice depends on the project scan results:
62
+ This table belongs to `prd-only`. Under `full-stack` and `prd-plus-interface-doc` a real contract source exists (the backend type, or the document), so no mock is written. Under `prd-only`, Code records the chosen strategy in `.peaks/_runtime/<sessionId>/rd/mock-plan.md` under a `## Mock Data Strategy` section — the same file mock rule 5 below requires before any mock file is produced. The choice depends on the project scan results:
21
63
 
22
64
  | Project data-fetching pattern | Recommended mock approach | Rationale |
23
65
  |---|---|---|
@@ -81,7 +123,7 @@ Never silently fall back to unauthenticated `fetch` or `WebFetch` for authentica
81
123
  1. Read `.peaks/_runtime/<sessionId>/prd/requests/<rid>.md` body.
82
124
  2. Lowercase + strip markdown; check regex `\b(页面|组件|表单|弹窗|表格|样式|布局|交互|UI|UX|page|component|form|modal|table|styling|layout|interaction|frontend|前端)\b`.
83
125
  3. If match count ≥ 1 → `frontendKeywordHit=true`.
84
- 4. If `frontendOnly` (from project-scan) is `true` and no keyword hit → UI joins anyway (frontend-only project, even non-visual changes may need visual sanity for regressions).
126
+ 4. If `frontendOnly` (back-compat boolean from `## Project mode` in project-scan — the UI-inclusion signal only; it is not the integration-mode router) is `true` and no keyword hit → UI joins anyway (frontend-only project, even non-visual changes may need visual sanity for regressions).
85
127
  5. If `frontendOnly` is `false` and no keyword hit → UI skipped.
86
128
 
87
129
  Code records the pre-flight result in `sc/swarm-plan.json` so the audit trail shows why UI was or was not included.
@@ -18,8 +18,10 @@ The command emits a stable JSON envelope with these fields you copy verbatim int
18
18
 
19
19
  - `archetype`: `greenfield | legacy-frontend | legacy-fullstack | frontend-monorepo | unknown`
20
20
  - `confidence`: `high | medium | low`
21
- - `frontendOnly`: `true | false`
21
+ - `frontendOnly`: `true | false` (back-compat boolean — keep it recorded)
22
22
  - `frontendOnlyReason`: short string explaining the decision
23
+ - `integrationMode`: `full-stack | prd-plus-interface-doc | prd-only` — which of the three frontend integration scenarios the project is in (`full-stack` = backend in this repo, contract is a shared interface; `prd-plus-interface-doc` = no backend here but an interface doc exists, derive the ACL from it; `prd-only` = no backend and no doc yet, mock and keep the boundary cheap to change)
24
+ - `integrationModeReason`: short string explaining the decision (`backend-detected | interface-doc-present | no-backend-no-interface-doc`)
23
25
  - `signals[]`: each signal's name, matched flag, and detail (paste under `## Archetype → Signals matched`)
24
26
  - `detected`: raw filesystem facts (package.json presence, backend frameworks, swagger paths, monorepo configs, src file count, lockfile age)
25
27
 
@@ -106,9 +108,16 @@ Grep `src/` for outdated patterns and list them as constraints in `project-scan.
106
108
  - Signals matched: <bullet list of signals that drove the decision>
107
109
 
108
110
  ## Project mode
111
+ - Integration mode: <full-stack | prd-plus-interface-doc | prd-only> (from `peaks scan archetype --json` → `.integrationMode`)
112
+ - Integration mode reason: <backend-detected | interface-doc-present | no-backend-no-interface-doc>
109
113
  - Frontend-only: <true | false>
110
114
  - Reason: <archetype-derived | user-stated | backend-detected>
111
115
 
116
+ The integration mode is one of exactly three values, all derived by `peaks scan archetype`
117
+ from signals it already detects — never judged by hand. The 0-1 bootstrap stub writes
118
+ `unknown` instead, because on that path no archetype report has been produced yet; treat
119
+ `unknown` as "not yet scanned", not as a fourth mode.
120
+
112
121
  ## Build tool
113
122
  - Framework: <name> <version>
114
123
  - Config file: <path>
@@ -128,6 +137,16 @@ Grep `src/` for outdated patterns and list them as constraints in `project-scan.
128
137
  - Routing: <name>
129
138
  - Data fetching: <name>
130
139
 
140
+ ## API
141
+ - Record file paths, not summaries — a later slice diffs against them, and a path that has moved is a finding.
142
+ - Base URL / env configuration: <env-var name(s) and the file that reads them, e.g. `VITE_API_BASE` in `src/config/env.ts` | none>
143
+ - Request wrapper + interceptors: <path of the single HTTP entry point, e.g. `src/services/http/client.ts`, and the mechanism used for cross-cutting concerns — axios interceptors, a `fetch` wrapper, ofetch hooks — with the auth/retry/header logic named | none>
144
+ - Error-handling shape: <what the wrapper hands callers on failure — thrown `Error` subclass, `{ ok, data, error }` result, HTTP-status branch — and the file where it is normalised>
145
+ - Hook convention: <naming pattern and directory, e.g. `use<Domain>` under `src/hooks/`, the return shape (`{ data, loading, error }` | `[data, actions]` | query object), and whether the hook does its own mapping or delegates>
146
+ - Endpoint inventory: <one bullet per endpoint the frontend actually calls — `METHOD /path` → calling file; when an interface doc exists, cross-check with `peaks scan api-diff <path-to-doc>`>
147
+ - Existing mock strategy: <mechanism in use today (module mock | MSW | static fixture module | inline) and where mock files live. Per `frontend-only-mode.md` mocks MUST NOT be inline in component files — an inline mock here is a finding, record it>
148
+ - DTO ↔ ViewModel boundary: <path of the per-domain mapper file, e.g. `src/mappers/user.mapper.ts`, and the internal ViewModel file it targets, per `skills/bee/peaks-rd/references/frontend-acl-mapper.md`. Record `none` explicitly when there is no mapper — that is a finding, not a blank>
149
+
131
150
  ## Library versions
132
151
  - Source: output of `peaks scan libraries --project <repo> --json` (see Gate A; cross-check diff imports against `schemas/library-breaking-changes.data.json` in `peaks-rd` preflight)
133
152
  - Total: <count from scan.libraries.totalCount>
@@ -21,6 +21,7 @@ Slice L3.2 ships 69 doctor checks. The most user-relevant ones:
21
21
  ## Integration (third-party hooks)
22
22
 
23
23
  - **`integration:gateguard-peaks-conflict`** — warns when `gateguard-fact-force` is installed without a `.peaks/**` skip pattern (the 3rd-party hook would block all peaks-qa .peaks/ artifact writes)
24
+ - **`integration:ecc-hooks-schema-drift`** — warns (cosmetic, never fails the run) when the 3rd-party ECC plugin's `hooks/hooks.json` carries keys Claude Code ignores (`$schema` at the root; `description` + `id` per matcher group), which is what prints `ecc: hooks.json: unknown keys ... ignored` at startup
24
25
 
25
26
  ## Skills
26
27