peaks-loop 4.0.10 → 4.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/config/eslint/.peaks-rules.cjs +123 -0
  3. package/dist/cli/commands/code-commands.js +2 -0
  4. package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
  5. package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
  6. package/dist/cli/commands/workspace/reconcile-command.js +5 -2
  7. package/dist/reporters/bdd-reporter.d.ts +36 -0
  8. package/dist/reporters/bdd-reporter.js +159 -0
  9. package/dist/services/audit/enforcers/active-skill-resolver.js +4 -1
  10. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  11. package/dist/services/code/orchestrator-can-do.js +205 -0
  12. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  13. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  14. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  15. package/dist/services/doctor/doctor-service/types.d.ts +9 -0
  16. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  17. package/dist/services/hooks/presence-marker-detector.js +45 -85
  18. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  19. package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
  20. package/dist/services/qa/bdd-test-style-verifier.js +268 -0
  21. package/dist/services/sc/sc-service.d.ts +11 -7
  22. package/dist/services/sc/sc-service.js +29 -26
  23. package/dist/services/session/binding-store.d.ts +6 -6
  24. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  25. package/dist/services/skills/hooks-settings-service.js +1 -1
  26. package/dist/services/skills/presence-lease-service.js +1 -0
  27. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  28. package/dist/services/skills/skill-presence-service.js +112 -102
  29. package/dist/services/skills/skill-statusline-service.js +73 -39
  30. package/docs/test-style-contract.md +135 -0
  31. package/package.json +6 -4
  32. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
  33. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +21 -1
  34. package/skills/peaks-code/SKILL.md +6 -0
  35. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Slice 2026-08-05-orchestrator-can-do-probe — service layer for
3
+ * `peaks code orchestrator-can-do`.
4
+ *
5
+ * Encodes the 2026-08-05 lesson (`.peaks/memory/2026-08-05-peaks-code-
6
+ * orchestrator-capability-misjudgment.md`): the peaks-code orchestrator
7
+ * MUST NOT Edit/Write `src/` files directly, but MUST delegate via
8
+ * `peaks sub-agent dispatch`. The decision "can this slice run in the
9
+ * current session" must be a structured probe — not a vibes call.
10
+ *
11
+ * The probe answers 4 boundary questions:
12
+ * Q1 — Is the change to source code? (keywords: src/, *.ts, *.tsx,
13
+ * *.js, package.json, tsconfig, workflows/). If yes → NOT a
14
+ * blocker; orchestrator delegates to sub-agent.
15
+ * Q2 — Can a sub-agent be dispatched? (probe `peaks sub-agent
16
+ * dispatch --role rd --help`). If no → blocker.
17
+ * Q3 — Does the slice require user decisions? (keywords: design,
18
+ * decide, ?, 选择, 决定). If yes → soft warning, NOT a blocker
19
+ * (the LLM should AskUserQuestion, which is cheap).
20
+ * Q4 — Is context usage sustainable? (probe `peaks code context-now
21
+ * --json`). ratio ≥ 0.95 → blocker (red-line); ≥ 0.85 →
22
+ * blocker (auto-compact-now).
23
+ *
24
+ * Decision rule:
25
+ * canDoInSession === (blockers.length === 0)
26
+ *
27
+ * Concrete suggestion when canDoInSession=true and slice touches
28
+ * source code:
29
+ * `peaks sub-agent dispatch rd --prompt "<slice-spec>" --request-id
30
+ * <rid> --project . --batch-id <uuid>`
31
+ *
32
+ * Pure-function module. The CLI shim (code-orchestrator-can-do.ts)
33
+ * adapts the envelope into the program's `ResultEnvelope<T>` shape.
34
+ */
35
+ import { execFile } from 'node:child_process';
36
+ import { promisify } from 'node:util';
37
+ import { randomUUID } from 'node:crypto';
38
+ const execFileAsync = promisify(execFile);
39
+ /** Slice 2026-08-05-orchestrator-can-do-probe: red-line threshold. */
40
+ export const ORCHESTRATOR_REDLINE_RATIO = 0.95;
41
+ /** Slice 2026-08-05-orchestrator-can-do-probe: pre-compact threshold. */
42
+ export const ORCHESTRATOR_PRECOMPACT_RATIO = 0.85;
43
+ /** Source-code keywords that signal "do NOT Edit/Write directly". */
44
+ export const SOURCE_CODE_KEYWORDS = [
45
+ 'src/',
46
+ 'source/',
47
+ '.ts',
48
+ '.tsx',
49
+ '.js',
50
+ '.jsx',
51
+ 'package.json',
52
+ 'tsconfig',
53
+ 'workflows/',
54
+ '.py',
55
+ '.go',
56
+ '.rs',
57
+ ];
58
+ /** Decision-marker keywords that signal "needs user AskUserQuestion". */
59
+ export const DECISION_KEYWORDS = [
60
+ 'design',
61
+ 'decide',
62
+ 'choose',
63
+ '?',
64
+ '选择',
65
+ '决定',
66
+ 'design decision',
67
+ 'user choice',
68
+ ];
69
+ export class OrchestratorCanDoError extends Error {
70
+ code;
71
+ constructor(message, code) {
72
+ super(message);
73
+ this.code = code;
74
+ this.name = 'OrchestratorCanDoError';
75
+ }
76
+ }
77
+ /**
78
+ * Q1: does the slice touch source code? Pure keyword scan over
79
+ * the slice-spec string. Case-insensitive substring match.
80
+ */
81
+ export function detectSourceCodeTouched(sliceSpec) {
82
+ const lower = sliceSpec.toLowerCase();
83
+ return SOURCE_CODE_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()));
84
+ }
85
+ /**
86
+ * Q3: does the slice require user decisions? Pure keyword scan
87
+ * over the slice-spec string.
88
+ */
89
+ export function detectRequiresUserDecision(sliceSpec) {
90
+ const lower = sliceSpec.toLowerCase();
91
+ return DECISION_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()));
92
+ }
93
+ /**
94
+ * Q2: probe `peaks sub-agent dispatch --role rd --help`. Returns
95
+ * true when the subprocess exits 0. Resolves to false on spawn
96
+ * failure or non-zero exit.
97
+ */
98
+ export async function probeSubAgentAvailable(projectRoot, peaksBin = 'peaks') {
99
+ try {
100
+ await execFileAsync(peaksBin, ['sub-agent', 'dispatch', '--role', 'rd', '--help'], {
101
+ cwd: projectRoot,
102
+ timeout: 5000,
103
+ });
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * Q4: probe `peaks code context-now --json`. Parses the data.ratio
112
+ * field. Falls back to {ratio: 0, source: 'unavailable'} when the
113
+ * subprocess fails or returns malformed JSON.
114
+ */
115
+ export async function probeContextRatio(projectRoot, peaksBin = 'peaks') {
116
+ try {
117
+ const { stdout } = await execFileAsync(peaksBin, ['code', 'context-now', '--project', projectRoot, '--json'], {
118
+ cwd: projectRoot,
119
+ timeout: 10000,
120
+ });
121
+ const parsed = JSON.parse(stdout);
122
+ const ratio = typeof parsed.data?.ratio === 'number' ? parsed.data.ratio : 0;
123
+ const source = typeof parsed.data?.source === 'string' ? parsed.data.source : 'unavailable';
124
+ return { ratio, source };
125
+ }
126
+ catch {
127
+ return { ratio: 0, source: 'unavailable' };
128
+ }
129
+ }
130
+ /**
131
+ * Build the structured OrchestratorCanDoResult. Pure over the 4 Q
132
+ * signals + sliceSpec. Decision rule is: canDoInSession === !blockers.
133
+ */
134
+ export function buildOrchestratorCanDoResult(input, signals) {
135
+ const blockers = [];
136
+ const warnings = [];
137
+ const suggestions = [];
138
+ // Q2 — sub-agent availability is a hard precondition.
139
+ if (!signals.q2SubAgentAvailable) {
140
+ blockers.push('sub-agent dispatch unavailable (peaks sub-agent dispatch --help failed)');
141
+ suggestions.push('verify peaks CLI is on PATH; check `peaks --version`');
142
+ }
143
+ // Q4 — context ratio gate. ≥0.95 → red-line; ≥0.85 → pre-compact.
144
+ if (signals.q4ContextRatio >= ORCHESTRATOR_REDLINE_RATIO) {
145
+ blockers.push(`context red-line (ratio=${signals.q4ContextRatio.toFixed(2)} ≥ ${ORCHESTRATOR_REDLINE_RATIO}); auto-compact now or push to next session`);
146
+ suggestions.push('peaks compact auto --execute');
147
+ }
148
+ else if (signals.q4ContextRatio >= ORCHESTRATOR_PRECOMPACT_RATIO) {
149
+ blockers.push(`context near limit (ratio=${signals.q4ContextRatio.toFixed(2)} ≥ ${ORCHESTRATOR_PRECOMPACT_RATIO}); auto-compact or push to next session`);
150
+ suggestions.push('peaks compact auto --execute');
151
+ }
152
+ // Q3 — user-decision keywords → soft warning, NOT a blocker. The
153
+ // LLM should AskUserQuestion, which is cheap.
154
+ if (signals.q3RequiresUserDecision) {
155
+ warnings.push('slice-spec contains decision keywords; AskUserQuestion before proceeding');
156
+ }
157
+ // Q1 — source-code touched is NOT a blocker. The orchestrator
158
+ // delegates via sub-agent dispatch.
159
+ if (signals.q1SourceCodeTouched && signals.q2SubAgentAvailable) {
160
+ const batchId = randomUUID();
161
+ const rid = 'rid-' + Date.now().toString(36);
162
+ suggestions.push(`peaks sub-agent dispatch rd --prompt "<slice-spec>" --request-id ${rid} --project ${input.projectRoot} --batch-id ${batchId}`);
163
+ }
164
+ // When canDoInSession=true and there's no source code touched,
165
+ // surface a generic "do it" suggestion.
166
+ const canDoInSession = blockers.length === 0;
167
+ if (canDoInSession && !signals.q1SourceCodeTouched) {
168
+ suggestions.push(`non-source-code slice; orchestrator may handle in-session (e.g. via Write/Edit tools or directly)`);
169
+ }
170
+ return {
171
+ canDoInSession,
172
+ blockers,
173
+ warnings,
174
+ suggestions,
175
+ contextRatio: signals.q4ContextRatio,
176
+ subAgentAvailable: signals.q2SubAgentAvailable,
177
+ q1SourceCodeTouched: signals.q1SourceCodeTouched,
178
+ q2SubAgentAvailable: signals.q2SubAgentAvailable,
179
+ q3RequiresUserDecision: signals.q3RequiresUserDecision,
180
+ q4ContextRatio: signals.q4ContextRatio,
181
+ };
182
+ }
183
+ /**
184
+ * Evaluate a slice-spec end-to-end. Probes Q2/Q4 via subprocess
185
+ * (overridable via test seams in `input`). Q1/Q3 are pure.
186
+ */
187
+ export async function evaluateOrchestratorCanDo(input) {
188
+ if (!input.sliceSpec || input.sliceSpec.trim().length === 0) {
189
+ throw new OrchestratorCanDoError('--slice-spec is required', 'MISSING_SLICE_SPEC');
190
+ }
191
+ const q1SourceCodeTouched = detectSourceCodeTouched(input.sliceSpec);
192
+ const q3RequiresUserDecision = detectRequiresUserDecision(input.sliceSpec);
193
+ const probeSubAgent = input.probeSubAgentAvailable ?? (() => probeSubAgentAvailable(input.projectRoot));
194
+ const probeContext = input.probeContextRatio ?? (() => probeContextRatio(input.projectRoot));
195
+ const [subAgentAvailable, ctxProbe] = await Promise.all([
196
+ probeSubAgent(),
197
+ probeContext(),
198
+ ]);
199
+ return buildOrchestratorCanDoResult(input, {
200
+ q1SourceCodeTouched,
201
+ q2SubAgentAvailable: subAgentAvailable,
202
+ q3RequiresUserDecision,
203
+ q4ContextRatio: ctxProbe.ratio,
204
+ });
205
+ }
@@ -33,7 +33,7 @@ function run({ options, presence }) {
33
33
  {
34
34
  id: 'skill-presence:current',
35
35
  ok: true,
36
- message: 'No active Peaks skill presence (.peaks/.active-skill.json absent or invalid)'
36
+ message: 'No active Peaks skill presence (no canonical lease under .peaks/_runtime/<sid>/leases/)'
37
37
  },
38
38
  {
39
39
  id: 'skill-presence:freshness',
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * Build-hygiene check that surfaces any leftover top-level session
6
6
  * dirs (`.peaks/<YYYY-MM-DD-session-<hex>>/`), the legacy runtime
7
- * dotfiles (`.peaks/.session.json`, `.peaks/.active-skill.json`),
8
- * OR per-change-id top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
7
+ * dotfiles (`.peaks/.session.json`), the stale single-slot
8
+ * presence file (`.peaks/_runtime/active-skill.json`, the deprecated
9
+ * project-level marker removed in slice 4.0.11), OR per-change-id
10
+ * top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
9
11
  *
10
12
  * The post-F3 canonical layout puts session dirs under
11
13
  * `.peaks/_runtime/<sid>/` and the runtime binding at
@@ -29,5 +31,6 @@ export declare function inspectWorkspaceLayout(opts: {
29
31
  topLevelScanner?: (root: string) => string[];
30
32
  dotfileScanner?: (root: string) => string[];
31
33
  perChangeIdScanner?: (root: string) => string[];
34
+ staleSingleSlotScanner?: (root: string) => string[];
32
35
  }): WorkspaceLayoutInspection;
33
36
  export declare const check: DoctorCheckPlugin;
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * Build-hygiene check that surfaces any leftover top-level session
6
6
  * dirs (`.peaks/<YYYY-MM-DD-session-<hex>>/`), the legacy runtime
7
- * dotfiles (`.peaks/.session.json`, `.peaks/.active-skill.json`),
8
- * OR per-change-id top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
7
+ * dotfiles (`.peaks/.session.json`), the stale single-slot
8
+ * presence file (`.peaks/_runtime/active-skill.json`, the deprecated
9
+ * project-level marker removed in slice 4.0.11), OR per-change-id
10
+ * top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
9
11
  *
10
12
  * The post-F3 canonical layout puts session dirs under
11
13
  * `.peaks/_runtime/<sid>/` and the runtime binding at
@@ -31,7 +33,24 @@ const SESSION_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
31
33
  * `0NN-session-...`, `YYYY-MM-DD-session-...`).
32
34
  */
33
35
  const PER_CHANGE_ID_PATTERN = /^\d{3}-\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
34
- const LEGACY_DOTFILES = ['.session.json', '.active-skill.json'];
36
+ // Slice 4.0.11 statusline-sid-scoped-lease C: `.peaks/.active-skill.json`
37
+ // is REMOVED from LEGACY_DOTFILES (the file should not exist on
38
+ // healthy post-4.0.11 projects; treating it as a generic legacy
39
+ // dotfile is misleading). The new single-slot presence file
40
+ // `.peaks/_runtime/active-skill.json` is reported separately under
41
+ // `STALE_SINGLE_SLOT_FILES` so the operator gets a clear "stale
42
+ // single-slot presence" message instead of a generic legacy warning.
43
+ const LEGACY_DOTFILES = ['.session.json'];
44
+ // Top-level single-slot presence files that are stale after the
45
+ // sid-scoped lease projection (slice 4.0.8) shipped. Reported
46
+ // separately from LEGACY_DOTFILES so the doctor message names the
47
+ // specific issue ("stale single-slot presence") and the operator
48
+ // can delete the file. The canonical sid-scoped lease at
49
+ // `.peaks/_runtime/<sid>/leases/presence-*.json` is the source of truth.
50
+ const STALE_SINGLE_SLOT_FILES = [
51
+ 'active-skill.json',
52
+ '.active-skill.json'
53
+ ];
35
54
  /**
36
55
  * Pure helper that inspects the on-disk workspace layout for
37
56
  * post-F3-canonical violations. Exported so tests can drive the
@@ -42,10 +61,12 @@ export function inspectWorkspaceLayout(opts) {
42
61
  const topLevel = opts.topLevelScanner ?? defaultTopLevelSessionDirScanner;
43
62
  const dotfiles = opts.dotfileScanner ?? defaultLegacyDotfileScanner;
44
63
  const perChangeId = opts.perChangeIdScanner ?? defaultPerChangeIdDirScanner;
64
+ const staleSingleSlot = opts.staleSingleSlotScanner ?? defaultStaleSingleSlotScanner;
45
65
  return {
46
66
  topLevelSessionDirs: safeList(() => topLevel(opts.projectRoot)),
47
67
  legacyDotfiles: safeList(() => dotfiles(opts.projectRoot)),
48
- perChangeIdDirs: safeList(() => perChangeId(opts.projectRoot))
68
+ perChangeIdDirs: safeList(() => perChangeId(opts.projectRoot)),
69
+ staleSingleSlotFiles: safeList(() => staleSingleSlot(opts.projectRoot))
49
70
  };
50
71
  }
51
72
  function safeList(reader) {
@@ -103,6 +124,39 @@ function defaultLegacyDotfileScanner(projectRoot) {
103
124
  }
104
125
  return offenders;
105
126
  }
127
+ /**
128
+ * Slice 4.0.11 statusline-sid-scoped-lease C: surface the deprecated
129
+ * single-slot presence files (`.peaks/_runtime/active-skill.json` and
130
+ * the legacy `.peaks/.active-skill.json`) as a separate offender
131
+ * category. The canonical sid-scoped lease projection
132
+ * (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the source of
133
+ * truth; these single-slot files should be deleted so the doctor
134
+ * report is clean.
135
+ *
136
+ * The reported path is the project-relative form so the doctor's
137
+ * human-readable output stays consistent with `legacyDotfiles`.
138
+ */
139
+ function defaultStaleSingleSlotScanner(projectRoot) {
140
+ const offenders = [];
141
+ const candidates = [
142
+ join(projectRoot, '.peaks', '_runtime', 'active-skill.json'),
143
+ join(projectRoot, '.peaks', '.active-skill.json')
144
+ ];
145
+ const reported = [
146
+ join('.peaks', '_runtime', 'active-skill.json'),
147
+ join('.peaks', '.active-skill.json')
148
+ ];
149
+ for (let i = 0; i < candidates.length; i++) {
150
+ const candidate = candidates[i];
151
+ const reportedPath = reported[i];
152
+ if (candidate === undefined || reportedPath === undefined)
153
+ continue;
154
+ if (existsSync(candidate)) {
155
+ offenders.push(reportedPath);
156
+ }
157
+ }
158
+ return offenders;
159
+ }
106
160
  function defaultPerChangeIdDirScanner(projectRoot) {
107
161
  const peaksRoot = join(projectRoot, '.peaks');
108
162
  if (!existsSync(peaksRoot))
@@ -136,7 +190,7 @@ function defaultPerChangeIdDirScanner(projectRoot) {
136
190
  function defaultWorkspaceLayoutProbe(projectRootResolver) {
137
191
  const projectRoot = projectRootResolver();
138
192
  if (projectRoot === null) {
139
- return { topLevelSessionDirs: [], legacyDotfiles: [], perChangeIdDirs: [] };
193
+ return { topLevelSessionDirs: [], legacyDotfiles: [], perChangeIdDirs: [], staleSingleSlotFiles: [] };
140
194
  }
141
195
  return inspectWorkspaceLayout({ projectRoot });
142
196
  }
@@ -148,22 +202,29 @@ function run({ options, projectRootResolver }) {
148
202
  // return a 2-field shape (no perChangeIdDirs). Treat missing
149
203
  // field as empty.
150
204
  const perChangeIdDirs = layout.perChangeIdDirs ?? [];
151
- if (layout.topLevelSessionDirs.length === 0 && layout.legacyDotfiles.length === 0 && perChangeIdDirs.length === 0) {
205
+ // Slice 4.0.11: probes injected by pre-4.0.11 tests also omit
206
+ // staleSingleSlotFiles. Treat missing field as empty.
207
+ const staleSingleSlotFiles = layout.staleSingleSlotFiles ?? [];
208
+ if (layout.topLevelSessionDirs.length === 0 &&
209
+ layout.legacyDotfiles.length === 0 &&
210
+ perChangeIdDirs.length === 0 &&
211
+ staleSingleSlotFiles.length === 0) {
152
212
  return [{
153
213
  id: 'build:workspace-layout-canonical',
154
214
  ok: true,
155
- message: 'Workspace layout is canonical: no top-level session dirs, no legacy runtime dotfiles, no per-change-id top-level dirs'
215
+ message: 'Workspace layout is canonical: no top-level session dirs, no legacy runtime dotfiles, no per-change-id top-level dirs, no stale single-slot presence files'
156
216
  }];
157
217
  }
158
218
  const offenders = [
159
219
  ...layout.topLevelSessionDirs.map((p) => `top-level session dir: ${p}`),
160
220
  ...layout.legacyDotfiles.map((p) => `legacy dotfile: ${p}`),
161
- ...perChangeIdDirs.map((p) => `per-change-id top-level dir: ${p}`)
221
+ ...perChangeIdDirs.map((p) => `per-change-id top-level dir: ${p}`),
222
+ ...staleSingleSlotFiles.map((p) => `stale single-slot presence: ${p}`)
162
223
  ];
163
224
  return [{
164
225
  id: 'build:workspace-layout-canonical',
165
226
  ok: false,
166
- message: `Workspace layout is not canonical. Offenders: ${offenders.join('; ')}. Run \`peaks workspace migrate --to-runtime --project <repo> --apply\` to consolidate.`
227
+ message: `Workspace layout is not canonical. Offenders: ${offenders.join('; ')}. Run \`peaks workspace migrate --to-runtime --project <repo> --apply\` to consolidate; delete stale single-slot presence files manually after migration.`
167
228
  }];
168
229
  }
169
230
  catch (error) {
@@ -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'];