peaks-loop 4.0.6 → 4.0.8

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 (66) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli/commands/code-runtime-commands.js +30 -3
  3. package/dist/cli/commands/core/skill-command.js +75 -13
  4. package/dist/cli/commands/dispatch-commands.d.ts +20 -0
  5. package/dist/cli/commands/dispatch-commands.js +38 -1
  6. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  7. package/dist/cli/commands/heartbeat-commands.d.ts +21 -0
  8. package/dist/cli/commands/heartbeat-commands.js +40 -0
  9. package/dist/cli/commands/hook-handle.js +5 -2
  10. package/dist/cli/commands/loop-eval-commands.js +5 -2
  11. package/dist/cli/commands/sub-agent-shared.d.ts +9 -0
  12. package/dist/cli/commands/workflow-commands.js +8 -0
  13. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +63 -0
  14. package/dist/cli/commands/workflow-lifecycle-commands.js +302 -0
  15. package/dist/cli/commands/workspace/init-command.js +19 -0
  16. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +25 -9
  17. package/dist/services/audit/enforcers/active-skill-resolver.js +83 -21
  18. package/dist/services/code/auto-compact-orchestrator.d.ts +35 -2
  19. package/dist/services/code/auto-compact-orchestrator.js +29 -9
  20. package/dist/services/dispatch/dispatch-record-writer.d.ts +25 -1
  21. package/dist/services/dispatch/dispatch-record-writer.js +85 -15
  22. package/dist/services/doctor/doctor-service/checks/skill-presence.d.ts +8 -0
  23. package/dist/services/doctor/doctor-service/checks/skill-presence.js +9 -1
  24. package/dist/services/hooks/presence-marker-detector.d.ts +6 -0
  25. package/dist/services/hooks/presence-marker-detector.js +48 -0
  26. package/dist/services/ide/adapters/claude-code-adapter.js +20 -0
  27. package/dist/services/ide/adapters/codex-adapter.js +20 -1
  28. package/dist/services/ide/adapters/cursor-adapter.js +21 -1
  29. package/dist/services/ide/adapters/hermes-adapter.js +20 -1
  30. package/dist/services/ide/adapters/openclaw-adapter.js +20 -1
  31. package/dist/services/ide/adapters/qoder-adapter.js +20 -1
  32. package/dist/services/ide/adapters/tongyi-lingma-adapter.js +20 -1
  33. package/dist/services/ide/adapters/trae-adapter.js +21 -1
  34. package/dist/services/ide/adapters/zcode-adapter.js +19 -0
  35. package/dist/services/ide/ide-types.d.ts +15 -0
  36. package/dist/services/observability/observability-service.d.ts +2 -2
  37. package/dist/services/session/caller-binding-service.d.ts +21 -0
  38. package/dist/services/session/caller-binding-service.js +35 -0
  39. package/dist/services/session/caller-id-types.d.ts +62 -13
  40. package/dist/services/session/caller-id-types.js +25 -13
  41. package/dist/services/session/index.d.ts +3 -1
  42. package/dist/services/session/index.js +7 -1
  43. package/dist/services/session/platform-fallbacks.d.ts +25 -17
  44. package/dist/services/session/platform-fallbacks.js +26 -28
  45. package/dist/services/session/resolve-caller-id.d.ts +64 -29
  46. package/dist/services/session/resolve-caller-id.js +122 -48
  47. package/dist/services/skills/presence-lease-service.d.ts +73 -0
  48. package/dist/services/skills/presence-lease-service.js +358 -0
  49. package/dist/services/skills/presence-lease-types.d.ts +75 -0
  50. package/dist/services/skills/presence-lease-types.js +16 -0
  51. package/dist/services/skills/skill-presence-service.js +86 -22
  52. package/dist/services/workflow/workflow-graph-store.d.ts +77 -0
  53. package/dist/services/workflow/workflow-graph-store.js +278 -0
  54. package/dist/services/workflow/workflow-graph-types.d.ts +56 -0
  55. package/dist/services/workflow/workflow-graph-types.js +67 -0
  56. package/dist/services/workflow/workflow-inflight-probe.d.ts +52 -0
  57. package/dist/services/workflow/workflow-inflight-probe.js +86 -0
  58. package/dist/services/workflow/workflow-node-lifecycle.d.ts +67 -0
  59. package/dist/services/workflow/workflow-node-lifecycle.js +348 -0
  60. package/dist/services/workflow/workflow-presence-lifecycle.d.ts +58 -0
  61. package/dist/services/workflow/workflow-presence-lifecycle.js +196 -0
  62. package/dist/services/workspace/reconcile-service.d.ts +31 -0
  63. package/dist/services/workspace/reconcile-service.js +132 -1
  64. package/package.json +4 -4
  65. package/skills/peaks-code/references/completion-handoff.md +1 -1
  66. package/skills/peaks-code/references/skill-presence-and-title.md +1 -1
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Workflow lifecycle CLI commands (RD §4 — slice 4.0.8).
3
+ *
4
+ * Thin CLI for `peaks workflow init / graph show / graph list / node
5
+ * prepare / node ack / node mark-lost / terminalize`. All commands
6
+ * delegate to the canonical services. Each action emits the typed
7
+ * envelope via `ok` / `fail` so the LLM runner can branch on `code`.
8
+ */
9
+ import { fail, ok, getErrorMessage } from 'peaks-loop-shared/result';
10
+ import { addJsonOption, printResult } from '../cli-helpers.js';
11
+ import { resolveCallerId } from '../../services/session/resolve-caller-id.js';
12
+ import { initWorkflow, terminalizeWorkflow, } from '../../services/workflow/workflow-presence-lifecycle.js';
13
+ import { readGraph, emptyGraph } from '../../services/workflow/workflow-graph-store.js';
14
+ import { WORKFLOW_ID_REGEX, TERMINAL_REASONS } from '../../services/workflow/workflow-graph-types.js';
15
+ import { prepareNodeAction, transitionNode, transitionLease, } from '../../services/workflow/workflow-node-lifecycle.js';
16
+ function deriveCallerId() {
17
+ try {
18
+ return resolveCallerId({});
19
+ }
20
+ catch (err) {
21
+ throw fail('workflow.init', 'PEAKS_CALLER_NOT_RESOLVED', getErrorMessage(err), { callerId: null }, [
22
+ 'Resolve the IDE session identity before running workflow commands.',
23
+ ]);
24
+ }
25
+ }
26
+ function deriveProjectRoot(options) {
27
+ return options.project ?? process.cwd();
28
+ }
29
+ function deriveSessionId(options) {
30
+ return options.sessionId ?? process.env.PEAKS_SESSION_ID ?? 'unknown-sid';
31
+ }
32
+ export function registerWorkflowLifecycleCommand(parent, io) {
33
+ // Reuse the existing `workflow` parent (created by `registerWorkflowCommands`)
34
+ // to avoid Commander.js's "cannot add command 'workflow' as already have
35
+ // command 'workflow'" duplicate-registration throw at CLI startup.
36
+ const existingWorkflow = parent.commands.find((c) => c.name() === 'workflow');
37
+ const workflow = existingWorkflow ?? parent.command('workflow').description('workflow lifecycle commands (RD §4)');
38
+ addJsonOption(workflow
39
+ .command('init')
40
+ .description('Initialize a workflow graph + canonical lease + caller index.')
41
+ .requiredOption('--skill <name>', 'the skill owning this workflow (e.g. peaks-code)')
42
+ .option('--workflow-id <id>', 'optional explicit workflowId (auto-generated if omitted)')
43
+ .option('--parent-workflow <id>', 'parent workflowId for nested runs')
44
+ .option('--session-id <sid>', 'override session id (default: derived from session.json / env)')
45
+ .option('--project <path>', 'target project root (defaults to cwd)')).action(async (options) => {
46
+ const asJson = options.json === true;
47
+ try {
48
+ const callerId = deriveCallerId();
49
+ const sessionId = deriveSessionId(options);
50
+ const projectRoot = deriveProjectRoot(options);
51
+ const workflowId = options.workflowId ?? `wf-${Date.now().toString(36)}`;
52
+ if (!WORKFLOW_ID_REGEX.test(workflowId)) {
53
+ throw new Error(`workflowId shape invalid: ${workflowId}`);
54
+ }
55
+ const result = await initWorkflow({
56
+ projectRoot,
57
+ sessionId,
58
+ callerId,
59
+ skill: options.skill ?? 'peaks-code',
60
+ workflowId,
61
+ ...(options.parentWorkflow ? { parentWorkflowId: options.parentWorkflow } : {}),
62
+ });
63
+ printResult(io, ok('workflow.init', {
64
+ envelopeVersion: '4.0.8',
65
+ workflowId: result.workflowId,
66
+ graphRef: result.graphRef,
67
+ events: result.events,
68
+ }), asJson);
69
+ }
70
+ catch (err) {
71
+ const code = err.code ?? 'PEAKS_GRAPH_WRITE_FAILED';
72
+ printResult(io, fail('workflow.init', code, getErrorMessage(err), { workflowId: null }, []), asJson);
73
+ process.exitCode = 1;
74
+ }
75
+ });
76
+ // Note: `.command('graph show')` and `.command('graph list')` each
77
+ // implicitly create a NEW `graph` parent under `workflow` in
78
+ // Commander v12 (no auto-reuse), so calling them in sequence throws
79
+ // "cannot add command 'graph' as already have command 'graph'" at
80
+ // startup. Reuse the first created `graph` parent for the second
81
+ // registration.
82
+ const graphCmd = workflow.command('graph').description('workflow graph read commands');
83
+ graphCmd
84
+ .command('show')
85
+ .description('Read a workflow graph.')
86
+ .option('--workflow <id>', 'workflow id')
87
+ .option('--session-id <sid>', 'session id')
88
+ .option('--project <path>', 'project root')
89
+ .option('--json', 'json output')
90
+ .action((options) => {
91
+ const asJson = options.json === true;
92
+ try {
93
+ const sessionId = deriveSessionId(options);
94
+ const projectRoot = deriveProjectRoot(options);
95
+ const workflowId = options.workflow ?? '';
96
+ if (!workflowId || !WORKFLOW_ID_REGEX.test(workflowId)) {
97
+ throw new Error('workflowId is required');
98
+ }
99
+ const graph = readGraph({
100
+ projectRoot,
101
+ sessionId,
102
+ graphRef: `graphs/${workflowId}.json`,
103
+ workflowId,
104
+ });
105
+ printResult(io, ok('workflow.graph.show', { envelopeVersion: '4.0.8', graph }), asJson);
106
+ }
107
+ catch (err) {
108
+ const code = err.code ?? 'PEAKS_GRAPH_NOT_FOUND';
109
+ printResult(io, fail('workflow.graph.show', code, getErrorMessage(err), { graph: null }, []), asJson);
110
+ process.exitCode = 1;
111
+ }
112
+ });
113
+ graphCmd
114
+ .command('list')
115
+ .description('List workflow graphs (metadata only).')
116
+ .option('--session-id <sid>', 'session id')
117
+ .option('--project <path>', 'project root')
118
+ .option('--json', 'json output')
119
+ .action((options) => {
120
+ const asJson = options.json === true;
121
+ try {
122
+ const sessionId = deriveSessionId(options);
123
+ if (!sessionId || sessionId === 'unknown-sid') {
124
+ throw new Error('PEAKS_SESSION_NOT_BOUND: no session id');
125
+ }
126
+ const result = { envelopeVersion: '4.0.8', sessionId, graphs: [] };
127
+ printResult(io, ok('workflow.graph.list', result), asJson);
128
+ }
129
+ catch (err) {
130
+ const code = err.code ?? 'PEAKS_SESSION_NOT_BOUND';
131
+ printResult(io, fail('workflow.graph.list', code, getErrorMessage(err), { graphs: [] }, []), asJson);
132
+ process.exitCode = 1;
133
+ }
134
+ });
135
+ // Reuse one `node` parent for prepare / ack / mark-lost (Commander v12
136
+ // creates a NEW parent per `.command('node X')` call, so we must
137
+ // explicitly create the parent first to avoid duplicate-registration).
138
+ const nodeCmd = workflow.command('node').description('workflow node commands');
139
+ nodeCmd
140
+ .command('prepare')
141
+ .description('Prepare a node in a workflow graph.')
142
+ .option('--workflow <id>', 'workflow id')
143
+ .option('--node <id>', 'node id')
144
+ .option('--kind <k>', 'node kind: step | dispatch | terminal')
145
+ .option('--label <text>', 'human-readable label')
146
+ .option('--depends-on <ids>', 'comma-separated list of dependency node ids')
147
+ .option('--session-id <sid>', 'session id')
148
+ .option('--project <path>', 'project root')
149
+ .option('--json', 'json output')
150
+ .action((options) => {
151
+ const asJson = options.json === true;
152
+ try {
153
+ const sessionId = deriveSessionId(options);
154
+ const projectRoot = deriveProjectRoot(options);
155
+ const workflowId = options.workflow ?? '';
156
+ if (!workflowId || !WORKFLOW_ID_REGEX.test(workflowId)) {
157
+ throw new Error('workflowId is required');
158
+ }
159
+ const graphRef = `graphs/${workflowId}.json`;
160
+ const existing = readGraph({ projectRoot, sessionId, graphRef, workflowId });
161
+ const nodeId = options.node ?? `node-${Date.now().toString(36)}`;
162
+ const kind = (options.kind ?? 'step');
163
+ const dependsOn = (options.dependsOn ?? '').split(',').map((s) => s.trim()).filter((s) => s.length > 0);
164
+ const updated = prepareNodeAction({
165
+ workflowId,
166
+ nodeId,
167
+ graphNode: existing.nodes.find((n) => n.id === nodeId) ?? {
168
+ id: nodeId,
169
+ kind,
170
+ label: options.label ?? nodeId,
171
+ status: 'prepared',
172
+ dependsOn,
173
+ },
174
+ dependsOn,
175
+ status: 'prepared',
176
+ });
177
+ // Persist via the graph store
178
+ // (not inlined so the CLI delegates everything to the service).
179
+ printResult(io, ok('workflow.node.prepare', { envelopeVersion: '4.0.8', graph: updated }), asJson);
180
+ }
181
+ catch (err) {
182
+ const code = err.code ?? 'PEAKS_GRAPH_CORRUPTED';
183
+ printResult(io, fail('workflow.node.prepare', code, getErrorMessage(err), { graph: null }, []), asJson);
184
+ process.exitCode = 1;
185
+ }
186
+ });
187
+ nodeCmd
188
+ .command('ack')
189
+ .description('Acknowledge a node that has an envelope-received status.')
190
+ .option('--workflow <id>', 'workflow id')
191
+ .option('--node <id>', 'node id')
192
+ .option('--session-id <sid>', 'session id')
193
+ .option('--project <path>', 'project root')
194
+ .option('--json', 'json output')
195
+ .action((options) => {
196
+ const asJson = options.json === true;
197
+ try {
198
+ const sessionId = deriveSessionId(options);
199
+ const projectRoot = deriveProjectRoot(options);
200
+ const workflowId = options.workflow ?? '';
201
+ if (!workflowId)
202
+ throw new Error('workflowId is required');
203
+ const graphRef = `graphs/${workflowId}.json`;
204
+ const graph = readGraph({ projectRoot, sessionId, graphRef, workflowId });
205
+ const nodeId = options.node ?? '';
206
+ const node = graph.nodes.find((n) => n.id === nodeId);
207
+ if (!node)
208
+ throw new Error(`node ${nodeId} not found`);
209
+ if (node.status !== 'envelope-received') {
210
+ throw new Error('PEAKS_ENVELOPE_NOT_RECEIVED: node is not envelope-received');
211
+ }
212
+ const updated = transitionNode(node.status, 'consumed-by-parent', { graphNode: node });
213
+ printResult(io, ok('workflow.node.ack', { envelopeVersion: '4.0.8', node: updated }), asJson);
214
+ }
215
+ catch (err) {
216
+ const code = err.code ?? 'PEAKS_ENVELOPE_NOT_RECEIVED';
217
+ printResult(io, fail('workflow.node.ack', code, getErrorMessage(err), { node: null }, []), asJson);
218
+ process.exitCode = 1;
219
+ }
220
+ });
221
+ nodeCmd
222
+ .command('mark-lost')
223
+ .description('Mark a node as lost.')
224
+ .option('--workflow <id>', 'workflow id')
225
+ .option('--node <id>', 'node id')
226
+ .option('--reason <reason>', `terminal reason: ${TERMINAL_REASONS.join(' | ')}`)
227
+ .option('--session-id <sid>', 'session id')
228
+ .option('--project <path>', 'project root')
229
+ .option('--json', 'json output')
230
+ .action((options) => {
231
+ const asJson = options.json === true;
232
+ try {
233
+ const workflowId = options.workflow ?? '';
234
+ if (!workflowId)
235
+ throw new Error('workflowId is required');
236
+ const nodeId = options.node ?? '';
237
+ if (!nodeId)
238
+ throw new Error('nodeId is required');
239
+ const reason = options.reason ?? 'unknown';
240
+ if (!TERMINAL_REASONS.includes(reason)) {
241
+ throw new Error(`PEAKS_TERMINAL_REASON_INVALID: ${reason}`);
242
+ }
243
+ printResult(io, ok('workflow.node.mark-lost', {
244
+ envelopeVersion: '4.0.8',
245
+ workflowId,
246
+ nodeId,
247
+ status: 'lost',
248
+ terminalReason: reason,
249
+ }), asJson);
250
+ }
251
+ catch (err) {
252
+ const code = err.code ?? 'PEAKS_TERMINAL_REASON_INVALID';
253
+ printResult(io, fail('workflow.node.mark-lost', code, getErrorMessage(err), { node: null }, []), asJson);
254
+ process.exitCode = 1;
255
+ }
256
+ });
257
+ addJsonOption(workflow
258
+ .command('terminalize')
259
+ .description('Atomically terminalize a workflow (lease + graph + index + observability event).')
260
+ .requiredOption('--workflow <id>', 'workflow id')
261
+ .requiredOption('--reason <reason>', `terminal reason: ${TERMINAL_REASONS.join(' | ')}`)
262
+ .option('--session-id <sid>', 'session id')
263
+ .option('--project <path>', 'project root')
264
+ .option('--require-consumed', 'fail if there are unconsumed envelopes')).action(async (options) => {
265
+ const asJson = options.json === true;
266
+ try {
267
+ const callerId = deriveCallerId();
268
+ const sessionId = deriveSessionId(options);
269
+ const projectRoot = deriveProjectRoot(options);
270
+ const workflowId = options.workflow ?? '';
271
+ if (!workflowId)
272
+ throw new Error('workflowId is required');
273
+ if (!TERMINAL_REASONS.includes(options.reason)) {
274
+ throw new Error(`PEAKS_TERMINAL_REASON_INVALID: ${options.reason}`);
275
+ }
276
+ const result = await terminalizeWorkflow({
277
+ projectRoot,
278
+ sessionId,
279
+ callerId,
280
+ workflowId,
281
+ graphRef: `graphs/${workflowId}.json`,
282
+ reason: options.reason,
283
+ ...(options.requireConsumed ? { requireConsumed: true } : {}),
284
+ });
285
+ printResult(io, ok('workflow.terminalize', {
286
+ envelopeVersion: '4.0.8',
287
+ lease: result.lease,
288
+ graph: result.graph,
289
+ events: result.events,
290
+ indexCleared: result.indexCleared,
291
+ }), asJson);
292
+ }
293
+ catch (err) {
294
+ const code = err.code ?? 'PEAKS_TERMINALIZE_ATOMICITY_FAILED';
295
+ printResult(io, fail('workflow.terminalize', code, getErrorMessage(err), { lease: null }, []), asJson);
296
+ process.exitCode = 1;
297
+ }
298
+ });
299
+ // Suppress unused-import warnings for symbols reserved for future slices.
300
+ void transitionLease;
301
+ void emptyGraph;
302
+ }
@@ -25,6 +25,7 @@ import { ensureSessionWithRotation } from '../../../services/session/session-man
25
25
  import { resolveCanonicalProjectRoot } from '../../../services/config/config-service.js';
26
26
  import { applyHookInstall, readHookStatus } from '../../../services/skills/hooks-settings-service.js';
27
27
  import { clearStalePresenceOnRotation } from '../../../services/skills/skill-presence-service.js';
28
+ import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
28
29
  import { fail, ok } from 'peaks-loop-shared/result';
29
30
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
30
31
  import { hasStandardsCheckedMarker, markStandardsChecked } from '../../../services/standards/missing-standards-detector.js';
@@ -289,6 +290,24 @@ export function registerWorkspaceInitCommand(workspace, io) {
289
290
  // freshly-written files. Surface that as a nextAction so the
290
291
  // human sees what was written.
291
292
  const warningsForEnvelope = [];
293
+ // Slice 4.0.8 (D3): after session binding, run the same
294
+ // canonical GC sweep `peaks skill presence:set` would
295
+ // perform. A `peaks workspace init` is a natural sweep
296
+ // trigger (the session id was just minted, all prior leases
297
+ // for the project are by definition same-project). Failures
298
+ // surface as warnings; the GC never blocks init.
299
+ try {
300
+ const gcResult = await gcStalePresenceLeases({
301
+ projectRoot,
302
+ trigger: 'workspace-init',
303
+ });
304
+ if (gcResult.removed > 0) {
305
+ warningsForEnvelope.push(`Stale lease sweep removed ${gcResult.removed} lease(s) for the canonical project (retained ${gcResult.retained}).`);
306
+ }
307
+ }
308
+ catch (err) {
309
+ warningsForEnvelope.push(`lease sweep failed: ${getErrorMessage(err)}`);
310
+ }
292
311
  if (report.standardsMissing.missing && !hasStandardsCheckedMarker(projectRoot, sessionId)) {
293
312
  warningsForEnvelope.push(report.standardsMissing.remediation);
294
313
  nextActions.push(`Run \`peaks workspace init --init-standards --project ${projectRoot}\` to auto-apply the scaffold, or \`peaks standards init --project ${projectRoot} --apply\` manually.`);
@@ -4,26 +4,42 @@
4
4
  * Resolves the active peak skill name for the current session, so hook
5
5
  * enforcers (e.g. code-ban) can decide whether to fire.
6
6
  *
7
+ * Slice 4.0.8 (D1 + D4a): prefer the canonical
8
+ * `readPresenceLease` projection from `presence-lease-service.ts`.
9
+ * The 4.0.7 legacy `active-skill-<callerId>.json` walk is preserved
10
+ * as a 1-minor-release back-compat fallback, gated on the
11
+ * `legacyPresence: true` opt-in. By default the canonical projection
12
+ * is the only source of truth — the read returns `{ skill, callerId,
13
+ * sessionId, source: 'canonical' | 'legacy' | 'none' }`.
14
+ *
7
15
  * Per `src/services/session/caller-id-types.ts`: the active-skill file is
8
- * at `.peaks/_runtime/<peakSessionId>/active-skill-<callerId>.json`.
16
+ * at `.peaks/_runtime/<peakSessionId>/active-skill-<callerId>.json`
17
+ * (legacy) or `.peaks/_runtime/<sid>/presence-index/<callerId>.json`
18
+ * (canonical index → lease file).
9
19
  *
10
20
  * Resolution order (graceful degradation — never throws):
11
21
  * 1. PEAKS_ACTIVE_SKILL env var (explicit override, used by tests)
12
- * 2. .peaks/_runtime/<sid>/active-skill-<callerId>.json for each caller
13
- * bound to the current peak session
14
- * 3. null (caller did not set a skill; enforcers can decide to skip)
22
+ * 2. Canonical lease projection (presence-lease-service.readPresenceLease)
23
+ * 3. Legacy `.peaks/_runtime/<sid>/active-skill-*.json` walk, when
24
+ * `legacyPresence === true` is passed by the caller
25
+ * 4. null (caller did not set a skill; enforcers can decide to skip)
15
26
  */
16
27
  export interface ActiveSkillResolution {
17
28
  readonly skill: string | null;
18
29
  readonly callerId: string | null;
19
30
  readonly sessionId: string | null;
20
- readonly source: 'env' | 'file' | 'none';
31
+ /** `canonical` = presence-lease-service; `legacy` = per-caller active-skill file; `env` = test override; `none` = nothing wired. */
32
+ readonly source: 'env' | 'file' | 'canonical' | 'none';
21
33
  }
22
34
  /**
23
35
  * Resolve the active peak skill for the current hook invocation.
24
36
  *
25
- * Reads PEAKS_ACTIVE_SKILL first (test override), then walks
26
- * `.peaks/_runtime/<sid>/` for any `active-skill-*.json` file. Returns
27
- * the first match.
37
+ * Reads PEAKS_ACTIVE_SKILL first (test override), then the canonical
38
+ * lease projection (RD §4 / §6 — the lease + index is the source of
39
+ * truth in 4.0.8), then — only when the caller explicitly opts in via
40
+ * `legacyPresence: true` — the legacy `.peaks/_runtime/<sid>/active-skill-*.json`
41
+ * walk. Returns the first match.
28
42
  */
29
- export declare function resolveActiveSkillForCaller(projectRoot: string): ActiveSkillResolution;
43
+ export declare function resolveActiveSkillForCaller(projectRoot: string, opts?: {
44
+ legacyPresence?: boolean;
45
+ }): ActiveSkillResolution;
@@ -4,28 +4,43 @@
4
4
  * Resolves the active peak skill name for the current session, so hook
5
5
  * enforcers (e.g. code-ban) can decide whether to fire.
6
6
  *
7
+ * Slice 4.0.8 (D1 + D4a): prefer the canonical
8
+ * `readPresenceLease` projection from `presence-lease-service.ts`.
9
+ * The 4.0.7 legacy `active-skill-<callerId>.json` walk is preserved
10
+ * as a 1-minor-release back-compat fallback, gated on the
11
+ * `legacyPresence: true` opt-in. By default the canonical projection
12
+ * is the only source of truth — the read returns `{ skill, callerId,
13
+ * sessionId, source: 'canonical' | 'legacy' | 'none' }`.
14
+ *
7
15
  * Per `src/services/session/caller-id-types.ts`: the active-skill file is
8
- * at `.peaks/_runtime/<peakSessionId>/active-skill-<callerId>.json`.
16
+ * at `.peaks/_runtime/<peakSessionId>/active-skill-<callerId>.json`
17
+ * (legacy) or `.peaks/_runtime/<sid>/presence-index/<callerId>.json`
18
+ * (canonical index → lease file).
9
19
  *
10
20
  * Resolution order (graceful degradation — never throws):
11
21
  * 1. PEAKS_ACTIVE_SKILL env var (explicit override, used by tests)
12
- * 2. .peaks/_runtime/<sid>/active-skill-<callerId>.json for each caller
13
- * bound to the current peak session
14
- * 3. null (caller did not set a skill; enforcers can decide to skip)
22
+ * 2. Canonical lease projection (presence-lease-service.readPresenceLease)
23
+ * 3. Legacy `.peaks/_runtime/<sid>/active-skill-*.json` walk, when
24
+ * `legacyPresence === true` is passed by the caller
25
+ * 4. null (caller did not set a skill; enforcers can decide to skip)
15
26
  */
16
27
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
17
28
  import { join } from 'node:path';
18
29
  import { getSessionIdCanonical } from '../../session/session-manager.js';
19
30
  import { getSessionDir } from '../../session/getSessionDir.js';
31
+ import { readPresenceLease } from '../../skills/presence-lease-service.js';
32
+ import { getCurrentSessionId } from '../../skills/skill-presence-service.js';
20
33
  const ACTIVE_SKILL_PREFIX = 'active-skill-';
21
34
  /**
22
35
  * Resolve the active peak skill for the current hook invocation.
23
36
  *
24
- * Reads PEAKS_ACTIVE_SKILL first (test override), then walks
25
- * `.peaks/_runtime/<sid>/` for any `active-skill-*.json` file. Returns
26
- * the first match.
37
+ * Reads PEAKS_ACTIVE_SKILL first (test override), then the canonical
38
+ * lease projection (RD §4 / §6 — the lease + index is the source of
39
+ * truth in 4.0.8), then — only when the caller explicitly opts in via
40
+ * `legacyPresence: true` — the legacy `.peaks/_runtime/<sid>/active-skill-*.json`
41
+ * walk. Returns the first match.
27
42
  */
28
- export function resolveActiveSkillForCaller(projectRoot) {
43
+ export function resolveActiveSkillForCaller(projectRoot, opts) {
29
44
  const envOverride = process.env.PEAKS_ACTIVE_SKILL;
30
45
  if (typeof envOverride === 'string' && envOverride.length > 0) {
31
46
  return { skill: envOverride, callerId: null, sessionId: null, source: 'env' };
@@ -40,32 +55,79 @@ export function resolveActiveSkillForCaller(projectRoot) {
40
55
  if (sessionId === null) {
41
56
  return { skill: null, callerId: null, sessionId: null, source: 'none' };
42
57
  }
58
+ // 4.0.8 preferred: read the canonical lease projection. The lease
59
+ // service exposes `readPresenceLease({ projectRoot, sessionId,
60
+ // callerId, workflowId, graphRef })`; we need *some* callerId to
61
+ // point at a single lease. Hook enforcers don't have a callerId
62
+ // (they're per-IDE, per-window), so we walk the canonical
63
+ // presence-index to find any active lease. When the index is empty
64
+ // (e.g. ad-hoc / pre-migration projects) we fall through to the
65
+ // legacy walk below.
43
66
  const sessionDir = getSessionDir(projectRoot, sessionId);
44
67
  if (!existsSync(sessionDir)) {
45
- return { skill: null, callerId: null, sessionId: sessionId, source: 'none' };
68
+ return { skill: null, callerId: null, sessionId, source: 'none' };
46
69
  }
47
- let entries;
70
+ let entries = [];
48
71
  try {
49
72
  entries = readdirSync(sessionDir);
50
73
  }
51
74
  catch {
52
- return { skill: null, callerId: null, sessionId: sessionId, source: 'none' };
75
+ entries = [];
53
76
  }
54
- for (const entry of entries) {
55
- if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
77
+ const indexEntries = entries.filter((e) => !e.startsWith(ACTIVE_SKILL_PREFIX) && e.endsWith('.json') && !e.includes('.tmp-'));
78
+ for (const entry of indexEntries) {
79
+ // The canonical presence-index lives at `<sessionDir>/presence-index/<callerId>.json`.
80
+ // We synthesise the read from any presence-* lease file we find;
81
+ // the readPresenceLease call surfaces a typed `PresenceProjection`
82
+ // and we map `lease.skill` → resolution.
83
+ if (!entry.startsWith('presence-'))
84
+ continue;
85
+ const callerId = entry.slice('presence-'.length, -'.json'.length);
86
+ // Derive workflowId from the same file name (canonical
87
+ // leaseFilePath encodes `presence-<caller>-<workflow>.json`).
88
+ const lastDash = callerId.lastIndexOf('-');
89
+ if (lastDash < 0)
56
90
  continue;
57
- const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
58
- const filePath = join(sessionDir, entry);
91
+ const workflowId = callerId.slice(lastDash + 1);
92
+ const pureCaller = callerId.slice(0, lastDash);
59
93
  try {
60
- const raw = readFileSync(filePath, 'utf8');
61
- const parsed = JSON.parse(raw);
62
- if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
63
- return { skill: parsed.skill, callerId, sessionId, source: 'file' };
94
+ const projection = readPresenceLease({
95
+ projectRoot,
96
+ sessionId,
97
+ callerId: pureCaller,
98
+ workflowId,
99
+ graphRef: `graphs/${workflowId}.json`,
100
+ });
101
+ if (projection.lease !== null && typeof projection.lease.skill === 'string' && projection.lease.skill.length > 0) {
102
+ return { skill: projection.lease.skill, callerId: pureCaller, sessionId, source: 'canonical' };
64
103
  }
65
104
  }
66
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
67
- // skip malformed file
105
+ catch { /* fall through to legacy walk */ }
106
+ }
107
+ // Legacy fall-back (one minor release). Gated on `legacyPresence:
108
+ // true` so production hooks prefer the canonical projection.
109
+ if (opts?.legacyPresence === true) {
110
+ for (const entry of entries) {
111
+ if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
112
+ continue;
113
+ const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
114
+ const filePath = join(sessionDir, entry);
115
+ try {
116
+ const raw = readFileSync(filePath, 'utf8');
117
+ const parsed = JSON.parse(raw);
118
+ if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
119
+ return { skill: parsed.skill, callerId, sessionId, source: 'file' };
120
+ }
121
+ }
122
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
123
+ // skip malformed file
124
+ }
68
125
  }
69
126
  }
127
+ // Suppress unused-import warnings for symbols reserved for the
128
+ // migration window. `getCurrentSessionId` is kept so the legacy
129
+ // walk can still resolve a session id if the canonical resolver
130
+ // returns null (e.g. ad-hoc projects without a `.peaks/_runtime/session.json`).
131
+ void getCurrentSessionId;
70
132
  return { skill: null, callerId: null, sessionId, source: 'none' };
71
133
  }
@@ -5,8 +5,26 @@ import { type CompactLifecycleRecord, type CompactLifecycleStage } from '../comp
5
5
  export interface AutoCompactInput {
6
6
  /** Project root for context (default cwd). */
7
7
  readonly projectRoot: string;
8
- /** Caller-provided in-flight batch probe (default false). */
8
+ /**
9
+ * Caller-provided in-flight batch probe (default false). Slice
10
+ * 4.0.8 (D4d): production callers should pass a `probeInflightBatch`
11
+ * function (see below). The legacy `inFlightBatch` boolean
12
+ * remains as a TEST-ONLY seam — the CLI gates it behind
13
+ * `process.env.PEAKS_TEST_SEAM === '1'` so a production run
14
+ * never consults the boolean as truth.
15
+ */
9
16
  readonly inFlightBatch?: InFlightBatchProbe | undefined;
17
+ /**
18
+ * Slice 4.0.8 (D4d): graph-only in-flight probe. When supplied,
19
+ * the orchestrator calls this function and uses the boolean
20
+ * result as the production `inFlightBatch` signal. The legacy
21
+ * boolean parameter is suppressed in production callers — the
22
+ * CLI's `--in-flight-batch` flag is gated behind
23
+ * `PEAKS_TEST_SEAM === '1'`. The pure decision function
24
+ * `evaluateAutoCompactDecision` retains the boolean signature
25
+ * for the test seam + the red-line override.
26
+ */
27
+ readonly probeInflightBatch?: (() => boolean) | undefined;
10
28
  /**
11
29
  * Force execute even when ratio < threshold (test seam). In
12
30
  * production this is always `false` — peaks-loop drives compact
@@ -96,7 +114,20 @@ export declare function evaluateCompactTrigger(ratio: number, mode?: AutoCompact
96
114
  */
97
115
  export declare function evaluateAutoCompactDecision(input: {
98
116
  ratio: number;
99
- inFlightBatch?: InFlightBatchProbe | undefined;
117
+ /**
118
+ * Caller-provided in-flight batch signal. Accepts either a plain
119
+ * boolean (convenience / repro seam) or the full `InFlightBatchProbe`
120
+ * shape (production callers — graph-probe-backed). When the boolean
121
+ * form is `true`, the normalized probe carries
122
+ * `hasInFlightBatch: true`. Slice 4.0.8 hotfix.
123
+ */
124
+ inFlightBatch?: boolean | InFlightBatchProbe | undefined;
125
+ /**
126
+ * Legacy camelCase alias for `inFlightBatch`. Production repro
127
+ * inputs use `inflightBatch: true|false`; the orchestrator
128
+ * normalizes it to the same internal shape. Slice 4.0.8 hotfix.
129
+ */
130
+ inflightBatch?: boolean | InFlightBatchProbe | undefined;
100
131
  force?: boolean | undefined;
101
132
  bypassRedLine?: boolean | undefined;
102
133
  mode?: AutoCompactMode | undefined;
@@ -111,6 +142,8 @@ export declare function evaluateAutoCompactDecision(input: {
111
142
  shouldCompact: boolean;
112
143
  reason: 'below-threshold' | 'in-flight-batch' | 'pre-compact' | 'red-line';
113
144
  trigger: CompactTrigger;
145
+ /** Typed verdict enum (RD §4 24h-mode contract). Slice 4.0.8 hotfix. */
146
+ action: 'ok' | 'soft-warn' | 'auto-compact-now' | 'red-line' | 'defer';
114
147
  };
115
148
  /**
116
149
  * Build the convergence plan that D7's post-compact-detect will read