peaks-loop 4.0.10 → 4.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/config/eslint/.peaks-rules.cjs +123 -0
- package/dist/cli/commands/code-commands.js +2 -0
- package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
- package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
- package/dist/cli/commands/workspace/reconcile-command.js +5 -2
- package/dist/reporters/bdd-reporter.d.ts +36 -0
- package/dist/reporters/bdd-reporter.js +159 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +4 -1
- package/dist/services/code/orchestrator-can-do.d.ts +114 -0
- package/dist/services/code/orchestrator-can-do.js +205 -0
- package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
- package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
- package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
- package/dist/services/doctor/doctor-service/types.d.ts +9 -0
- package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
- package/dist/services/hooks/presence-marker-detector.js +45 -85
- package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
- package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
- package/dist/services/qa/bdd-test-style-verifier.js +268 -0
- package/dist/services/sc/sc-service.d.ts +11 -7
- package/dist/services/sc/sc-service.js +29 -26
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/skills/hooks-settings-service.d.ts +1 -1
- package/dist/services/skills/hooks-settings-service.js +1 -1
- package/dist/services/skills/presence-lease-service.js +1 -0
- package/dist/services/skills/skill-presence-service.d.ts +0 -1
- package/dist/services/skills/skill-presence-service.js +112 -102
- package/dist/services/skills/skill-statusline-service.js +73 -39
- package/docs/test-style-contract.md +135 -0
- package/package.json +6 -4
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +21 -1
- package/skills/peaks-code/SKILL.md +6 -0
- package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
|
@@ -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 (
|
|
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
|
|
8
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
* canonical
|
|
20
|
-
*
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
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:
|
|
65
|
+
return { skill: freshest.skill };
|
|
75
66
|
}
|
|
76
67
|
/**
|
|
77
|
-
* Slice 4.0.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* `
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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(
|
|
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.
|
|
132
|
-
*
|
|
133
|
-
* `
|
|
134
|
-
* canonical
|
|
135
|
-
*
|
|
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 =
|
|
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'];
|