peaks-loop 4.0.11 → 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 (25) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli/commands/code-commands.js +2 -0
  3. package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
  4. package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
  5. package/dist/cli/commands/workspace/reconcile-command.js +5 -2
  6. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  7. package/dist/services/code/orchestrator-can-do.js +205 -0
  8. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  9. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  10. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  11. package/dist/services/doctor/doctor-service/types.d.ts +9 -0
  12. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  13. package/dist/services/hooks/presence-marker-detector.js +45 -85
  14. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  15. package/dist/services/sc/sc-service.d.ts +11 -7
  16. package/dist/services/sc/sc-service.js +29 -26
  17. package/dist/services/session/binding-store.d.ts +6 -6
  18. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  19. package/dist/services/skills/hooks-settings-service.js +1 -1
  20. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  21. package/dist/services/skills/skill-presence-service.js +112 -102
  22. package/dist/services/skills/skill-statusline-service.js +71 -39
  23. package/package.json +4 -4
  24. package/skills/peaks-code/SKILL.md +6 -0
  25. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -55,6 +55,15 @@ export type WorkspaceLayoutInspection = {
55
55
  * to an empty array when the field is missing.
56
56
  */
57
57
  perChangeIdDirs?: string[];
58
+ /**
59
+ * Slice 4.0.11 statusline-sid-scoped-lease C — single-slot
60
+ * presence files that are stale after the sid-scoped lease
61
+ * projection shipped in 4.0.8. Reported separately from
62
+ * `legacyDotfiles` so the doctor message names the specific
63
+ * "stale single-slot presence" condition. Optional for back-compat
64
+ * with probes injected by older tests.
65
+ */
66
+ staleSingleSlotFiles?: string[];
58
67
  };
59
68
  export type WorkspaceLayoutProbe = () => WorkspaceLayoutInspection;
60
69
  /**
@@ -13,10 +13,12 @@ export declare const PRESENCE_MARKER_WARNING: readonly ["Peaks skill context may
13
13
  /**
14
14
  * Pure read-only presence-marker detection. No I/O side effects.
15
15
  *
16
- * Slice 4.0.8: the canonical 4.0.7 behavior is preserved (read legacy
17
- * `active-skill.json` or `.peaks/.active-skill.json`, project the
18
- * `Peaks-Loop Skill:` marker). The new diagnostic for a broken
19
- * canonical graph is surfaced via the `warning` field, NOT swallowed,
20
- * so the statusline / hook / doctor consumers can render it.
16
+ * Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
17
+ * `.peaks/_runtime/active-skill.json` (and legacy
18
+ * `.peaks/.active-skill.json`) read paths are removed. The
19
+ * canonical sid-scoped lease projection at
20
+ * `.peaks/_runtime/<sid>/leases/presence-*.json` is the only source.
21
+ * The `warning` field is preserved for downstream consumers
22
+ * (statusline, doctor, hooks).
21
23
  */
22
24
  export declare function detectPresenceMarker(input: DetectPresenceMarkerInput): DetectPresenceMarkerResult;
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { getSessionId } from '../session/session-manager.js';
2
+ import { listPresenceLeases } from '../skills/presence-lease-service.js';
3
3
  /**
4
4
  * Slice 028 (Q1=A): hook-based skill-presence marker detection.
5
5
  *
@@ -17,7 +17,7 @@ import { resolve } from 'node:path';
17
17
  * `detectPresenceMarker({ project, latestAssistantMessage })`
18
18
  * and gets back:
19
19
  *
20
- * - `active`: whether an active-skill marker was found on disk.
20
+ * - `active`: whether an active lease was found on disk.
21
21
  * - `skill?`: the active skill name, if any.
22
22
  * - `markerFound`: whether the latest assistant message carries the
23
23
  * expected `Peaks-Loop Skill:` / `Peaks-Loop Gate:`
@@ -30,94 +30,52 @@ import { resolve } from 'node:path';
30
30
  * expected to provide the absolute project root (peaks-loop convention
31
31
  * from the standards-commands family — see dev-preference rule
32
32
  * `project-option-is-canonical-project-root-source`).
33
+ *
34
+ * Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
35
+ * project-level `active-skill.json` single-slot file is REMOVED
36
+ * from this module. The canonical sid-scoped lease projection
37
+ * (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the only
38
+ * source of truth; no legacy fallback is retained.
33
39
  */
34
- const PRESENCE_CANONICAL_PATH = '.peaks/_runtime/active-skill.json';
35
- const PRESENCE_LEGACY_PATH = '.peaks/.active-skill.json';
36
40
  const MARKER_PRIMARY = 'Peaks-Loop Skill:';
37
41
  const MARKER_SECONDARY = 'Peaks-Loop Gate:';
38
- const SKILL_NAME_RE = /"skill"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/;
39
42
  export const PRESENCE_MARKER_WARNING = [
40
43
  'Peaks skill context may have been lost from this conversation; please re-invoke /peaks-<skill>.'
41
44
  ];
42
- function readPresenceFile(absolutePath) {
43
- if (!existsSync(absolutePath))
44
- return null;
45
- let raw;
46
- try {
47
- raw = readFileSync(absolutePath, 'utf8');
48
- }
49
- catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
50
- if (err instanceof ReferenceError)
51
- throw err; // surface module-load bugs
52
- if (err instanceof SyntaxError)
53
- throw err; // surface parse bugs
54
- return null; // only swallow IO errors
55
- }
56
- let parsed;
57
- try {
58
- parsed = JSON.parse(raw);
59
- }
60
- catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
61
- if (err instanceof ReferenceError)
62
- throw err; // surface module-load bugs
63
- if (err instanceof SyntaxError)
64
- throw err; // surface parse bugs
65
- return null; // only swallow IO errors
66
- }
67
- if (parsed === null || typeof parsed !== 'object')
45
+ /**
46
+ * Slice 4.0.11 statusline-sid-scoped-lease C: read the canonical
47
+ * sid-scoped lease projection. Returns the in-flight lease with the
48
+ * most recent `lastHeartbeat`, or null when no leases exist for the
49
+ * bound session. Picked the most-recent-in-flight lease (mirrors the
50
+ * statusline `callerId === null` back-compat path).
51
+ */
52
+ function readCanonicalLease(projectRoot) {
53
+ const sessionId = getSessionId(projectRoot);
54
+ if (sessionId === null)
68
55
  return null;
69
- const skillMatch = SKILL_NAME_RE.exec(JSON.stringify(parsed));
70
- if (skillMatch === null)
56
+ const leases = listPresenceLeases(projectRoot, sessionId);
57
+ const inFlight = leases.filter((l) => l.status === 'running' || l.status === 'preparing');
58
+ if (inFlight.length === 0)
71
59
  return null;
72
- if (typeof skillMatch[1] !== 'string' || skillMatch[1].length === 0)
60
+ // Sort by lastHeartbeat desc, return the freshest.
61
+ inFlight.sort((a, b) => b.lastHeartbeat.localeCompare(a.lastHeartbeat));
62
+ const freshest = inFlight[0];
63
+ if (freshest === undefined)
73
64
  return null;
74
- return { skill: skillMatch[1] };
65
+ return { skill: freshest.skill };
75
66
  }
76
67
  /**
77
- * Slice 4.0.8: surface a typed `PEAKS_GRAPH_REF_BROKEN` warning
78
- * when the canonical presence lease / index points at a missing or
79
- * corrupt graph. The legacy `active-skill.json` walk never
80
- * inspected graphRef; in 4.0.8 we surface the broken graph in the
81
- * `warnings` array so downstream consumers (statusline, doctor,
82
- * hooks) can render the diagnostic instead of silently rendering
83
- * "active" for a half-wired presence.
68
+ * Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
69
+ * broken-graph diagnostic walked the legacy `active-skill.json`
70
+ * `graphRef` field. The canonical lease projection surfaces broken
71
+ * graphs as a typed `PEAKS_GRAPH_REF_BROKEN` lease status at read
72
+ * time (see `presence-lease-service.readJsonStrict`), so a separate
73
+ * diagnostic walk is no longer needed. Kept as a no-op for the
74
+ * public-surface stability of `detectPresenceMarker` (the warning
75
+ * field shape stays the same); returns null unless the canonical
76
+ * graph lookup is wired in a future slice.
84
77
  */
85
- function tryDetectBrokenGraph(projectRoot) {
86
- try {
87
- const presenceIndexDir = resolve(projectRoot, '.peaks', '_runtime');
88
- if (!existsSync(presenceIndexDir))
89
- return null;
90
- // Best-effort: walk a single directory level for the legacy
91
- // `active-skill.json` indicator. A broken graphRef would be
92
- // surfaced by the canonical lease service (PEAKS_GRAPH_REF_BROKEN
93
- // is the typed error), but the marker detector is a *read* —
94
- // we don't import the lease service here. The diagnostic is
95
- // best-effort: we report the index file's `graphRef` field
96
- // when it names a missing file.
97
- const legacyPath = resolve(projectRoot, '.peaks', '_runtime', 'active-skill.json');
98
- if (!existsSync(legacyPath))
99
- return null;
100
- const raw = readFileSync(legacyPath, 'utf8');
101
- const parsed = JSON.parse(raw);
102
- if (typeof parsed.graphRef !== 'string')
103
- return null;
104
- const graphPath = resolve(projectRoot, '.peaks', '_runtime', parsed.graphRef);
105
- if (!existsSync(graphPath))
106
- return 'PEAKS_GRAPH_REF_BROKEN';
107
- }
108
- catch { /* swallow — diagnostic only */ }
109
- return null;
110
- }
111
- function readPresenceBackCompat(project) {
112
- const projectRoot = resolve(project);
113
- const canonicalPath = resolve(projectRoot, PRESENCE_CANONICAL_PATH);
114
- const legacyPath = resolve(projectRoot, PRESENCE_LEGACY_PATH);
115
- for (const candidate of [canonicalPath, legacyPath]) {
116
- const parsed = readPresenceFile(candidate);
117
- if (parsed === null)
118
- continue;
119
- return { skill: parsed.skill, path: candidate };
120
- }
78
+ function tryDetectBrokenGraph(_projectRoot) {
121
79
  return null;
122
80
  }
123
81
  function messageHasMarker(message) {
@@ -128,16 +86,18 @@ function messageHasMarker(message) {
128
86
  /**
129
87
  * Pure read-only presence-marker detection. No I/O side effects.
130
88
  *
131
- * Slice 4.0.8: the canonical 4.0.7 behavior is preserved (read legacy
132
- * `active-skill.json` or `.peaks/.active-skill.json`, project the
133
- * `Peaks-Loop Skill:` marker). The new diagnostic for a broken
134
- * canonical graph is surfaced via the `warning` field, NOT swallowed,
135
- * so the statusline / hook / doctor consumers can render it.
89
+ * Slice 4.0.11 statusline-sid-scoped-lease C: the deprecated
90
+ * `.peaks/_runtime/active-skill.json` (and legacy
91
+ * `.peaks/.active-skill.json`) read paths are removed. The
92
+ * canonical sid-scoped lease projection at
93
+ * `.peaks/_runtime/<sid>/leases/presence-*.json` is the only source.
94
+ * The `warning` field is preserved for downstream consumers
95
+ * (statusline, doctor, hooks).
136
96
  */
137
97
  export function detectPresenceMarker(input) {
138
98
  const project = input.project;
139
99
  const message = input.latestAssistantMessage ?? '';
140
- const presence = readPresenceBackCompat(project);
100
+ const presence = readCanonicalLease(project);
141
101
  if (presence === null) {
142
102
  return { active: false, markerFound: false };
143
103
  }
@@ -42,9 +42,7 @@ const SKIP_DIRS = new Set([
42
42
  'caller',
43
43
  'callers',
44
44
  'sop-state',
45
- 'system',
46
- 'active-skill.json',
47
- '.active-skill.json'
45
+ 'system'
48
46
  ]);
49
47
  const SESSION_DIR_RE = /^\d{4}-\d{2}-\d{2}-session-/;
50
48
  const TECH_DOC_MARKERS = ['## Architecture', '## Component', '## API', '## Dependencies'];
@@ -53,10 +53,10 @@ export type CommitBoundary = {
53
53
  };
54
54
  /**
55
55
  * Resolution sources for `resolveArtifactSession`, in priority order.
56
- * - `active-skill`: the orchestrator's active-skill marker
57
- * (`.peaks/_runtime/active-skill.json`, with a one-minor-release
58
- * fallback to `.peaks/.active-skill.json`) `sessionId` points to a
59
- * session dir that owns the slice's marker artifact.
56
+ * - `active-skill`: the canonical sid-scoped lease projection
57
+ * (`.peaks/_runtime/<sid>/leases/presence-*.json`) for the bound
58
+ * session id. When at least one in-flight lease exists under the
59
+ * session dir, that session owns the slice's marker artifact.
60
60
  * - `session-json`: the workspace binding in
61
61
  * `.peaks/_runtime/session.json` (with back-compat fallback to
62
62
  * `.peaks/.session.json`) points to a session dir that owns the
@@ -74,9 +74,13 @@ export type ResolvedArtifactSession = {
74
74
  * Resolve the session id that owns the slice's artifacts using a 3-tier
75
75
  * precedence:
76
76
  *
77
- * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
78
- * fallback to `.peaks/.active-skill.json`) if it points to a real
79
- * session that owns the slice.
77
+ * 1. The canonical sid-scoped lease projection under the bound
78
+ * session id (workspace binding + at least one in-flight lease
79
+ * in `.peaks/_runtime/<sid>/leases/`). Slice 4.0.11 statusline-
80
+ * sid-scoped-lease C collapsed the previous
81
+ * `.peaks/_runtime/active-skill.json` reader into this tier —
82
+ * the session id is now read from the canonical workspace
83
+ * binding (was tier 2), gated on a live lease.
80
84
  * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
81
85
  * fallback to `.peaks/.session.json`) if it points to a real
82
86
  * session that owns the slice.
@@ -5,6 +5,7 @@ import { isInsidePath } from '../../shared/path-utils.js';
5
5
  import { getWorkspaceConfigForPath } from '../config/config-service.js';
6
6
  import { getArtifactRemoteRepo, getArtifactWorkspaceStatus, getLocalArtifactPath } from '../artifacts/workspace-service.js';
7
7
  import { getSessionId } from '../session/session-manager.js';
8
+ import { listPresenceLeases } from '../skills/presence-lease-service.js';
8
9
  const REQUIRED_ARTIFACTS = [
9
10
  { name: 'retention-boundary.md', path: ['sc', 'retention-boundary.md'] },
10
11
  { name: 'change-impact.json', path: ['sc', 'change-impact.json'] },
@@ -120,33 +121,31 @@ function isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, ch
120
121
  }
121
122
  }
122
123
  /**
123
- * Read the orchestrator's active-skill marker and return its
124
- * `sessionId`, or null when the file is missing / malformed.
124
+ * Resolve the canonical sid-scoped lease session id (replaces the
125
+ * removed `.peaks/_runtime/active-skill.json` single-slot marker
126
+ * reader). Reads the workspace session binding
127
+ * (`.peaks/_runtime/session.json`, with back-compat fallback to
128
+ * `.peaks/.session.json`) and returns its `sessionId` when at least
129
+ * one in-flight canonical lease exists under
130
+ * `.peaks/_runtime/<sid>/leases/`.
125
131
  *
126
- * As of slice 2026-06-05-peaks-runtime-layer the canonical home is
127
- * `<projectRoot>/.peaks/_runtime/active-skill.json`. The legacy
128
- * `<projectRoot>/.peaks/.active-skill.json` is consulted as a
129
- * one-minor-release back-compat fallback: if the new path is
130
- * absent but the legacy path is present and valid, we use the
131
- * legacy value. The new path always wins when both exist.
132
+ * Slice 4.0.11 statusline-sid-scoped-lease C: the project-level
133
+ * `active-skill.json` is removed; the canonical sid-scoped lease
134
+ * projection (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the
135
+ * authoritative source. The session-id is now read from the
136
+ * workspace binding (which was already tier 2 in the old resolver),
137
+ * gated on the existence of an in-flight lease so a stale binding
138
+ * with no live presence does not produce a false `active-skill`
139
+ * tier hit. Returns null when the session binding is missing /
140
+ * malformed, or when no in-flight lease exists.
132
141
  */
133
142
  function readActiveSkillSessionId(projectRoot) {
134
- const newPath = join(projectRoot, '.peaks', '_runtime', 'active-skill.json');
135
- const legacyPath = join(projectRoot, '.peaks', '.active-skill.json');
136
- const pathToRead = existsSync(newPath) ? newPath : legacyPath;
137
- if (!existsSync(pathToRead))
138
- return null;
139
- try {
140
- const raw = readFileSync(pathToRead, 'utf8');
141
- const parsed = JSON.parse(raw);
142
- if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
143
- return parsed.sessionId;
144
- }
145
- }
146
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
143
+ const sessionId = readSessionJsonBinding(projectRoot);
144
+ if (sessionId === null)
147
145
  return null;
148
- }
149
- return null;
146
+ const leases = listPresenceLeases(projectRoot, sessionId);
147
+ const hasInFlight = leases.some((l) => l.status === 'running' || l.status === 'preparing');
148
+ return hasInFlight ? sessionId : null;
150
149
  }
151
150
  /**
152
151
  * Read the workspace session binding and return its `sessionId`,
@@ -284,9 +283,13 @@ function readdirSyncSafe(dir) {
284
283
  * Resolve the session id that owns the slice's artifacts using a 3-tier
285
284
  * precedence:
286
285
  *
287
- * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
288
- * fallback to `.peaks/.active-skill.json`) if it points to a real
289
- * session that owns the slice.
286
+ * 1. The canonical sid-scoped lease projection under the bound
287
+ * session id (workspace binding + at least one in-flight lease
288
+ * in `.peaks/_runtime/<sid>/leases/`). Slice 4.0.11 statusline-
289
+ * sid-scoped-lease C collapsed the previous
290
+ * `.peaks/_runtime/active-skill.json` reader into this tier —
291
+ * the session id is now read from the canonical workspace
292
+ * binding (was tier 2), gated on a live lease.
290
293
  * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
291
294
  * fallback to `.peaks/.session.json`) if it points to a real
292
295
  * session that owns the slice.
@@ -24,14 +24,14 @@ export declare const InstanceRecordSchema: z.ZodObject<{
24
24
  callerId: z.ZodString;
25
25
  lastHeartbeat: z.ZodString;
26
26
  }, "strip", z.ZodTypeAny, {
27
+ lastHeartbeat: string;
27
28
  startedAt: string;
28
29
  roles: string[];
29
30
  callerId: string;
30
- lastHeartbeat: string;
31
31
  }, {
32
+ lastHeartbeat: string;
32
33
  startedAt: string;
33
34
  callerId: string;
34
- lastHeartbeat: string;
35
35
  roles?: string[] | undefined;
36
36
  }>;
37
37
  export declare const BindingSchema: z.ZodObject<{
@@ -45,14 +45,14 @@ export declare const BindingSchema: z.ZodObject<{
45
45
  callerId: z.ZodString;
46
46
  lastHeartbeat: z.ZodString;
47
47
  }, "strip", z.ZodTypeAny, {
48
+ lastHeartbeat: string;
48
49
  startedAt: string;
49
50
  roles: string[];
50
51
  callerId: string;
51
- lastHeartbeat: string;
52
52
  }, {
53
+ lastHeartbeat: string;
53
54
  startedAt: string;
54
55
  callerId: string;
55
- lastHeartbeat: string;
56
56
  roles?: string[] | undefined;
57
57
  }>>;
58
58
  }, "strip", z.ZodTypeAny, {
@@ -61,10 +61,10 @@ export declare const BindingSchema: z.ZodObject<{
61
61
  pid: number;
62
62
  scope: string;
63
63
  instances: Record<string, {
64
+ lastHeartbeat: string;
64
65
  startedAt: string;
65
66
  roles: string[];
66
67
  callerId: string;
67
- lastHeartbeat: string;
68
68
  }>;
69
69
  }, {
70
70
  lastHeartbeat: string;
@@ -72,9 +72,9 @@ export declare const BindingSchema: z.ZodObject<{
72
72
  pid: number;
73
73
  scope: string;
74
74
  instances: Record<string, {
75
+ lastHeartbeat: string;
75
76
  startedAt: string;
76
77
  callerId: string;
77
- lastHeartbeat: string;
78
78
  roles?: string[] | undefined;
79
79
  }>;
80
80
  }>;
@@ -67,7 +67,7 @@ export type HookInstallOptions = {
67
67
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
68
68
  export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
69
69
  /** Default (claude-code) hook command — kept as a stable export for tests. */
70
- export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
70
+ export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\" --json";
71
71
  export type HookInstallPlan = {
72
72
  scope: HookScope;
73
73
  settingsPath: string;
@@ -11,7 +11,7 @@ import { getAdapter } from '../ide/ide-registry.js';
11
11
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
12
12
  export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
13
13
  /** Default (claude-code) hook command — kept as a stable export for tests. */
14
- export const HOOK_ENFORCE_COMMAND = `peaks gate enforce --project "\${CLAUDE_PROJECT_DIR}"`;
14
+ export const HOOK_ENFORCE_COMMAND = `peaks gate enforce --project "\${CLAUDE_PROJECT_DIR}" --json`;
15
15
  /**
16
16
  * Per-IDE hook command + sentinel. The default (Claude Code) uses the
17
17
  * legacy `peaks gate enforce` surface; Trae / Cursor / Codex (Cursor-style
@@ -54,7 +54,6 @@ export type SkillPresence = {
54
54
  * fallback (slice 2026-06-26-unknown-sid-fallback-fix).
55
55
  */
56
56
  export declare function getCurrentSessionId(projectRootOverride?: string): string | null;
57
- export declare function exportSkillPresence(projectRootOverride?: string): string;
58
57
  /**
59
58
  * Write the per-caller active-skill marker to
60
59
  * `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json` (D6). Returns