peaks-loop 4.0.7 → 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 (89) hide show
  1. package/CHANGELOG.md +46 -288
  2. package/dist/cli/commands/code-job-shape-commands.js +8 -11
  3. package/dist/cli/commands/code-mode-gate-commands.js +1 -43
  4. package/dist/cli/commands/code-runtime-commands.js +36 -14
  5. package/dist/cli/commands/core/skill-command.js +75 -13
  6. package/dist/cli/commands/core/standards-command.js +5 -50
  7. package/dist/cli/commands/dispatch-commands.d.ts +20 -0
  8. package/dist/cli/commands/dispatch-commands.js +39 -38
  9. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  10. package/dist/cli/commands/heartbeat-commands.d.ts +21 -0
  11. package/dist/cli/commands/heartbeat-commands.js +40 -0
  12. package/dist/cli/commands/hook-handle.js +5 -2
  13. package/dist/cli/commands/hooks-commands.js +9 -26
  14. package/dist/cli/commands/loop-eval-commands.js +5 -2
  15. package/dist/cli/commands/sub-agent-commands.js +0 -2
  16. package/dist/cli/commands/sub-agent-shared.d.ts +7 -9
  17. package/dist/cli/commands/workflow-commands.js +8 -0
  18. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +63 -0
  19. package/dist/cli/commands/workflow-lifecycle-commands.js +302 -0
  20. package/dist/cli/commands/workspace/init-command.js +19 -0
  21. package/dist/services/audit/backing-detector.d.ts +2 -19
  22. package/dist/services/audit/backing-detector.js +7 -66
  23. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +25 -9
  24. package/dist/services/audit/enforcers/active-skill-resolver.js +83 -21
  25. package/dist/services/audit/enforcers/lint-audit-regression.js +1 -10
  26. package/dist/services/code/auto-compact-orchestrator.d.ts +35 -2
  27. package/dist/services/code/auto-compact-orchestrator.js +29 -9
  28. package/dist/services/context/build-dispatch-system-prompt.js +0 -18
  29. package/dist/services/dispatch/dispatch-record-writer.d.ts +25 -1
  30. package/dist/services/dispatch/dispatch-record-writer.js +85 -15
  31. package/dist/services/dispatch/test-tool-detection.d.ts +0 -14
  32. package/dist/services/dispatch/test-tool-detection.js +0 -22
  33. package/dist/services/doctor/doctor-service/checks/skill-presence.d.ts +8 -0
  34. package/dist/services/doctor/doctor-service/checks/skill-presence.js +9 -1
  35. package/dist/services/hooks/presence-marker-detector.d.ts +6 -0
  36. package/dist/services/hooks/presence-marker-detector.js +48 -0
  37. package/dist/services/ide/adapters/claude-code-adapter.js +20 -0
  38. package/dist/services/ide/adapters/codex-adapter.js +20 -1
  39. package/dist/services/ide/adapters/cursor-adapter.js +21 -1
  40. package/dist/services/ide/adapters/hermes-adapter.js +20 -1
  41. package/dist/services/ide/adapters/openclaw-adapter.js +20 -1
  42. package/dist/services/ide/adapters/qoder-adapter.js +20 -1
  43. package/dist/services/ide/adapters/tongyi-lingma-adapter.js +20 -1
  44. package/dist/services/ide/adapters/trae-adapter.js +21 -1
  45. package/dist/services/ide/adapters/zcode-adapter.js +19 -0
  46. package/dist/services/ide/ide-types.d.ts +15 -0
  47. package/dist/services/observability/observability-service.d.ts +2 -2
  48. package/dist/services/scan/archetype-service.js +2 -52
  49. package/dist/services/session/caller-binding-service.d.ts +21 -0
  50. package/dist/services/session/caller-binding-service.js +35 -0
  51. package/dist/services/session/caller-id-types.d.ts +62 -13
  52. package/dist/services/session/caller-id-types.js +25 -13
  53. package/dist/services/session/index.d.ts +4 -2
  54. package/dist/services/session/index.js +8 -2
  55. package/dist/services/session/platform-fallbacks.d.ts +25 -17
  56. package/dist/services/session/platform-fallbacks.js +26 -28
  57. package/dist/services/session/resolve-caller-id.d.ts +64 -29
  58. package/dist/services/session/resolve-caller-id.js +122 -48
  59. package/dist/services/session/session-manager.d.ts +0 -29
  60. package/dist/services/session/session-manager.js +0 -33
  61. package/dist/services/skills/hooks-settings-service.d.ts +0 -41
  62. package/dist/services/skills/hooks-settings-service.js +5 -61
  63. package/dist/services/skills/presence-lease-service.d.ts +73 -0
  64. package/dist/services/skills/presence-lease-service.js +358 -0
  65. package/dist/services/skills/presence-lease-types.d.ts +75 -0
  66. package/dist/services/skills/presence-lease-types.js +16 -0
  67. package/dist/services/skills/skill-presence-service.js +86 -22
  68. package/dist/services/slice/slice-check-service.js +0 -59
  69. package/dist/services/standards/project-standards-service.js +0 -44
  70. package/dist/services/workflow/workflow-graph-store.d.ts +77 -0
  71. package/dist/services/workflow/workflow-graph-store.js +278 -0
  72. package/dist/services/workflow/workflow-graph-types.d.ts +56 -0
  73. package/dist/services/workflow/workflow-graph-types.js +67 -0
  74. package/dist/services/workflow/workflow-inflight-probe.d.ts +52 -0
  75. package/dist/services/workflow/workflow-inflight-probe.js +86 -0
  76. package/dist/services/workflow/workflow-node-lifecycle.d.ts +67 -0
  77. package/dist/services/workflow/workflow-node-lifecycle.js +348 -0
  78. package/dist/services/workflow/workflow-presence-lifecycle.d.ts +58 -0
  79. package/dist/services/workflow/workflow-presence-lifecycle.js +196 -0
  80. package/dist/services/workspace/reconcile-service.d.ts +31 -0
  81. package/dist/services/workspace/reconcile-service.js +132 -1
  82. package/package.json +4 -4
  83. package/skills/peaks-code/SKILL.md +5 -43
  84. package/skills/peaks-code/references/completion-handoff.md +1 -1
  85. package/skills/peaks-code/references/skill-presence-and-title.md +1 -1
  86. package/dist/cli/commands/sub-agent-exec-command.d.ts +0 -25
  87. package/dist/cli/commands/sub-agent-exec-command.js +0 -132
  88. package/dist/services/standards/find-skills-integration.d.ts +0 -59
  89. package/dist/services/standards/find-skills-integration.js +0 -225
@@ -63,21 +63,6 @@ export type HookInstallOptions = {
63
63
  * hook entries use it.
64
64
  */
65
65
  readonly skipProgress?: boolean;
66
- /**
67
- * Slice 4.0.7-PR-meta-5: opt-in Edit-enforcement hook entry. When
68
- * `true`, the install emits the additional PreToolUse hook entry
69
- * pointing at `pre-tool-edit-enforcement.sh`. Default `false` to
70
- * preserve the existing install behavior; users who want the
71
- * Edit layer enforced pass `--with-edit-enforcement` on the CLI
72
- * (or set `PEAKS_INSTALL_EDIT_ENFORCEMENT=1`).
73
- */
74
- readonly withEditEnforcement?: boolean;
75
- /**
76
- * Slice 4.0.7-PR-meta-5: opt-in scope-counter hook entry. When
77
- * `true`, the install emits the additional PreToolUse hook entry
78
- * pointing at `pre-tool-scope-counter.sh`. Default `false`.
79
- */
80
- readonly withScopeCounter?: boolean;
81
66
  };
82
67
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
83
68
  export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
@@ -144,32 +129,6 @@ export type PeaksHookEntry = {
144
129
  command: string;
145
130
  event: string;
146
131
  };
147
- /**
148
- * Slice 4.0.7-PR-meta-1: the Edit-enforcement hook entry. Off by
149
- * default; opt in via `peaks hooks install --with-edit-enforcement`
150
- * (or `PEAKS_INSTALL_EDIT_ENFORCEMENT=1` env). The entry points at
151
- * the new `pre-tool-edit-enforcement.sh` script in
152
- * `src/services/hooks/`. The matcher targets Edit / Write / MultiEdit
153
- * / NotebookEdit (Claude Code: "Edit|Write|MultiEdit|NotebookEdit";
154
- * Trae: same). When the hook fires, the script blocks src/** edits
155
- * without an RD artifact on disk (per slice 4.0.7-PR-meta-1 design).
156
- */
157
- export declare const EDIT_ENFORCEMENT_HOOK_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
158
- export declare const EDIT_ENFORCEMENT_HOOK_SENTINEL = "peaks edit enforcement";
159
- export declare const EDIT_ENFORCEMENT_HOOK_EVENT = "PreToolUse";
160
- /**
161
- * Slice 4.0.7-PR-meta-3: the scope-counter hook entry. Off by
162
- * default. Tracks cumulative application-source edits in the
163
- * active session and emits a fail-LOUD reflection reminder at
164
- * thresholds 5, 10, 20. The LLM cannot continue without writing
165
- * to `.peaks/_runtime/<sid>/scope-reflection.md` once a threshold
166
- * is hit. Different from the harness-side SCOPE WARNING (which
167
- * is informational noise); the meta-3 hook surfaces a checkpoint
168
- * the LLM must reference in its next turn.
169
- */
170
- export declare const SCOPE_COUNTER_HOOK_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
171
- export declare const SCOPE_COUNTER_HOOK_SENTINEL = "peaks scope counter";
172
- export declare const SCOPE_COUNTER_HOOK_EVENT = "PreToolUse";
173
132
  /**
174
133
  * The set of Skills whose use is denied at the IDE permissions layer for
175
134
  * every `peaks hooks install` call. Each entry is the namespace-qualified
@@ -57,14 +57,6 @@ function resolveIde(options) {
57
57
  function resolveSkipProgress(options) {
58
58
  return options?.skipProgress === true;
59
59
  }
60
- /** Slice 4.0.7-PR-meta-5: read the withEditEnforcement opt-in flag. */
61
- function resolveWithEditEnforcement(options) {
62
- return options?.withEditEnforcement === true;
63
- }
64
- /** Slice 4.0.7-PR-meta-5: read the withScopeCounter opt-in flag. */
65
- function resolveWithScopeCounter(options) {
66
- return options?.withScopeCounter === true;
67
- }
68
60
  /** Resolve settings root dir for a scope. */
69
61
  function resolveSettingsRoot(scope, projectRoot) {
70
62
  if (scope === 'global')
@@ -173,32 +165,6 @@ export function readInstalledEntriesFromSettings(settings, ide) {
173
165
  }
174
166
  return result;
175
167
  }
176
- /**
177
- * Slice 4.0.7-PR-meta-1: the Edit-enforcement hook entry. Off by
178
- * default; opt in via `peaks hooks install --with-edit-enforcement`
179
- * (or `PEAKS_INSTALL_EDIT_ENFORCEMENT=1` env). The entry points at
180
- * the new `pre-tool-edit-enforcement.sh` script in
181
- * `src/services/hooks/`. The matcher targets Edit / Write / MultiEdit
182
- * / NotebookEdit (Claude Code: "Edit|Write|MultiEdit|NotebookEdit";
183
- * Trae: same). When the hook fires, the script blocks src/** edits
184
- * without an RD artifact on disk (per slice 4.0.7-PR-meta-1 design).
185
- */
186
- export const EDIT_ENFORCEMENT_HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
187
- export const EDIT_ENFORCEMENT_HOOK_SENTINEL = 'peaks edit enforcement';
188
- export const EDIT_ENFORCEMENT_HOOK_EVENT = 'PreToolUse';
189
- /**
190
- * Slice 4.0.7-PR-meta-3: the scope-counter hook entry. Off by
191
- * default. Tracks cumulative application-source edits in the
192
- * active session and emits a fail-LOUD reflection reminder at
193
- * thresholds 5, 10, 20. The LLM cannot continue without writing
194
- * to `.peaks/_runtime/<sid>/scope-reflection.md` once a threshold
195
- * is hit. Different from the harness-side SCOPE WARNING (which
196
- * is informational noise); the meta-3 hook surfaces a checkpoint
197
- * the LLM must reference in its next turn.
198
- */
199
- export const SCOPE_COUNTER_HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
200
- export const SCOPE_COUNTER_HOOK_SENTINEL = 'peaks scope counter';
201
- export const SCOPE_COUNTER_HOOK_EVENT = 'PreToolUse';
202
168
  /**
203
169
  * Compute the per-IDE peaks hook entries to merge into the settings file.
204
170
  * Replaces the slice #1 hardcoded `PEAKS_HOOK_ENTRIES` constant; the constant
@@ -216,33 +182,11 @@ export const SCOPE_COUNTER_HOOK_EVENT = 'PreToolUse';
216
182
  * can find + remove any stale progress-start entry that an older
217
183
  * `peaks hooks install` may have written before this slice.
218
184
  */
219
- function resolveHookEntries(ide, options = {}) {
185
+ function resolveHookEntries(ide, _skipProgress = false) {
220
186
  const spec = resolveHookSpec(ide);
221
- const entries = [
187
+ return [
222
188
  { sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
223
189
  ];
224
- // Slice 4.0.7-PR-meta-1: opt-in Edit enforcement. Default false
225
- // to preserve the existing `peaks hooks install` behavior; users
226
- // who want the LLM Edit layer enforced call install with the
227
- // opt-in flag or env var.
228
- if ('withEditEnforcement' in options && options.withEditEnforcement === true) {
229
- entries.push({
230
- sentinel: EDIT_ENFORCEMENT_HOOK_SENTINEL,
231
- matcher: EDIT_ENFORCEMENT_HOOK_MATCHER,
232
- command: 'peaks edit-enforcement',
233
- event: EDIT_ENFORCEMENT_HOOK_EVENT
234
- });
235
- }
236
- // Slice 4.0.7-PR-meta-3: opt-in scope counter. Default false.
237
- if ('withScopeCounter' in options && options.withScopeCounter === true) {
238
- entries.push({
239
- sentinel: SCOPE_COUNTER_HOOK_SENTINEL,
240
- matcher: SCOPE_COUNTER_HOOK_MATCHER,
241
- command: 'peaks scope-counter',
242
- event: SCOPE_COUNTER_HOOK_EVENT
243
- });
244
- }
245
- return entries;
246
190
  }
247
191
  /**
248
192
  * Legacy sentinel set used by uninstall + status to find and remove stale
@@ -449,7 +393,7 @@ export function planHookInstall(scope, projectRoot, options) {
449
393
  };
450
394
  }
451
395
  /** Merge all peaks-managed hook entries into settings, preserving all other keys and hooks. */
452
- function withHooksInstalledForIde(settings, ide, options = {}) {
396
+ function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
453
397
  const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
454
398
  ? settings.hooks
455
399
  : {};
@@ -457,7 +401,7 @@ function withHooksInstalledForIde(settings, ide, options = {}) {
457
401
  // beforeToolCall; Claude: gate-enforce on PreToolUse). Group by event
458
402
  // so each event array is independently merged.
459
403
  const ourByEvent = new Map();
460
- for (const spec of resolveHookEntries(ide, options)) {
404
+ for (const spec of resolveHookEntries(ide)) {
461
405
  const list = ourByEvent.get(spec.event) ?? [];
462
406
  list.push(spec);
463
407
  ourByEvent.set(spec.event, list);
@@ -530,7 +474,7 @@ export function applyHookInstall(scope, projectRoot, options) {
530
474
  // entry. The chain cannot run raw `git worktree add` (or
531
475
  // `podman run`) without an explicit `peaks hooks uninstall`
532
476
  // first. Both helpers are pure + idempotent.
533
- const merged = withTriggeredDenyList(withSuperpowersSkillDenylist(withHooksInstalledForIde(settings, ide, { withEditEnforcement: resolveWithEditEnforcement(options), withScopeCounter: resolveWithScopeCounter(options) })));
477
+ const merged = withTriggeredDenyList(withSuperpowersSkillDenylist(withHooksInstalledForIde(settings, ide)));
534
478
  atomicWriteJson(settingsPath, merged);
535
479
  return { ...baseResult, alreadyInstalled: false, applied: true };
536
480
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Canonical skill presence lease service (RD §4 — slice 4.0.8).
3
+ *
4
+ * Owns the canonical lease + caller index write/read path under
5
+ * `.peaks/_runtime/<sid>/leases/` and `.peaks/_runtime/<sid>/presence-index/`.
6
+ * Fail-closed on missing session / unresolved caller (RD §3 D1). Never
7
+ * inspects vendor env vars — caller resolution is the IDE adapter's
8
+ * responsibility, and the resolved callerId is passed in as a typed
9
+ * argument. Same-project GC lives here (RD §3 D3).
10
+ */
11
+ import { type SkillPresenceLease, type PresenceIndex, type PresenceProjection, type GcResult } from './presence-lease-types.js';
12
+ import { type TerminalReason } from '../workflow/workflow-graph-types.js';
13
+ export interface PresenceError extends Error {
14
+ readonly code: string;
15
+ }
16
+ export declare const STALE_HEARTBEAT_MS: number;
17
+ export declare const STALE_STARTED_AFTER_MS: number;
18
+ /** Resolve the lease directory. */
19
+ export declare function leaseDir(projectRoot: string, sessionId: string): string;
20
+ /** Resolve the caller index directory. */
21
+ export declare function presenceIndexDir(projectRoot: string, sessionId: string): string;
22
+ /** Resolve the migrations directory. */
23
+ export declare function migrationsDir(projectRoot: string, sessionId: string): string;
24
+ export interface SetPresenceLeaseInput {
25
+ readonly projectRoot: string;
26
+ readonly sessionId?: string | null;
27
+ readonly callerId?: string | null;
28
+ readonly adapterEnv?: Record<string, string | undefined>;
29
+ readonly workflowId: string;
30
+ readonly graphRef: string;
31
+ readonly skill: string;
32
+ readonly depth?: number;
33
+ readonly parentWorkflowId?: string;
34
+ readonly now?: string;
35
+ readonly staleLeases?: ReadonlyArray<SkillPresenceLease>;
36
+ readonly mode?: string;
37
+ readonly gate?: string;
38
+ }
39
+ export interface SetPresenceLeaseResult {
40
+ readonly lease: SkillPresenceLease;
41
+ readonly index: PresenceIndex;
42
+ readonly gc: GcResult;
43
+ }
44
+ export interface ReadPresenceLeaseInput {
45
+ readonly projectRoot: string;
46
+ readonly sessionId?: string | null;
47
+ readonly callerId: string;
48
+ readonly workflowId: string;
49
+ readonly graphRef: string;
50
+ }
51
+ export interface MarkPresenceLostInput {
52
+ readonly projectRoot: string;
53
+ readonly sessionId?: string | null;
54
+ readonly callerId: string;
55
+ readonly workflowId: string;
56
+ readonly graphRef: string;
57
+ readonly status?: 'terminalized' | 'lost';
58
+ readonly reason: TerminalReason;
59
+ readonly now?: string;
60
+ readonly expectedCallerId?: string;
61
+ }
62
+ export interface GcPresenceLeasesInput {
63
+ readonly projectRoot: string;
64
+ readonly now?: string;
65
+ readonly leases?: ReadonlyArray<SkillPresenceLease>;
66
+ readonly trigger?: 'manual' | 'workspace-init' | 'presence-set';
67
+ }
68
+ export declare function setPresenceLease(input: SetPresenceLeaseInput): SetPresenceLeaseResult;
69
+ export declare function readPresenceLease(input: ReadPresenceLeaseInput): PresenceProjection;
70
+ export declare function markPresenceLost(input: MarkPresenceLostInput): SkillPresenceLease;
71
+ export declare function gcStalePresenceLeases(input: GcPresenceLeasesInput): GcResult;
72
+ export declare function listPresenceLeases(projectRoot: string, sessionId: string): SkillPresenceLease[];
73
+ export declare function clearPresenceForCaller(projectRoot: string, sessionId: string, callerId: string): boolean;
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Canonical skill presence lease service (RD §4 — slice 4.0.8).
3
+ *
4
+ * Owns the canonical lease + caller index write/read path under
5
+ * `.peaks/_runtime/<sid>/leases/` and `.peaks/_runtime/<sid>/presence-index/`.
6
+ * Fail-closed on missing session / unresolved caller (RD §3 D1). Never
7
+ * inspects vendor env vars — caller resolution is the IDE adapter's
8
+ * responsibility, and the resolved callerId is passed in as a typed
9
+ * argument. Same-project GC lives here (RD §3 D3).
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, resolve, sep } from 'node:path';
13
+ import { WORKFLOW_ID_REGEX, } from '../workflow/workflow-graph-types.js';
14
+ import { PEAKS_SESSION_NOT_BOUND, PEAKS_CALLER_NOT_RESOLVED, PEAKS_GRAPH_REF_BROKEN, } from '../workflow/workflow-graph-store.js';
15
+ function presenceError(code, message) {
16
+ const err = new Error(message);
17
+ err.name = 'PresenceError';
18
+ err.code = code;
19
+ return err;
20
+ }
21
+ export const STALE_HEARTBEAT_MS = 60 * 60 * 1000; // 1h
22
+ export const STALE_STARTED_AFTER_MS = 24 * 60 * 60 * 1000 + 30 * 60 * 1000; // 24h30m (RD §3 calls for >24h; the +30m buffer disambiguates leases that started in the 24-25h window so only leases older than 24h30m are GC'd)
23
+ /** Validate `callerId` shape. Returns trimmed value or throws. */
24
+ function validateCallerId(callerId) {
25
+ if (typeof callerId !== 'string') {
26
+ throw presenceError(PEAKS_CALLER_NOT_RESOLVED, 'callerId is not a string');
27
+ }
28
+ const trimmed = callerId.trim();
29
+ if (trimmed.length === 0) {
30
+ throw presenceError(PEAKS_CALLER_NOT_RESOLVED, 'callerId is empty');
31
+ }
32
+ if (!/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed)) {
33
+ throw presenceError(PEAKS_CALLER_NOT_RESOLVED, `callerId shape invalid: ${trimmed}`);
34
+ }
35
+ return trimmed;
36
+ }
37
+ function validateSessionId(sessionId) {
38
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
39
+ throw presenceError(PEAKS_SESSION_NOT_BOUND, 'sessionId is required');
40
+ }
41
+ if (!WORKFLOW_ID_REGEX.test(sessionId)) {
42
+ throw presenceError(PEAKS_SESSION_NOT_BOUND, `sessionId shape invalid: ${sessionId}`);
43
+ }
44
+ return sessionId;
45
+ }
46
+ function validateWorkflowId(workflowId) {
47
+ if (typeof workflowId !== 'string' || workflowId.length === 0) {
48
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, 'workflowId required');
49
+ }
50
+ if (!WORKFLOW_ID_REGEX.test(workflowId)) {
51
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `workflowId shape invalid: ${workflowId}`);
52
+ }
53
+ return workflowId;
54
+ }
55
+ /** Resolve the session runtime root. */
56
+ function sessionRuntimeRoot(projectRoot, sessionId) {
57
+ const root = resolve(projectRoot);
58
+ return join(root, '.peaks', '_runtime', sessionId);
59
+ }
60
+ /** Resolve the lease directory. */
61
+ export function leaseDir(projectRoot, sessionId) {
62
+ return join(sessionRuntimeRoot(projectRoot, sessionId), 'leases');
63
+ }
64
+ /** Resolve the caller index directory. */
65
+ export function presenceIndexDir(projectRoot, sessionId) {
66
+ return join(sessionRuntimeRoot(projectRoot, sessionId), 'presence-index');
67
+ }
68
+ /** Resolve the migrations directory. */
69
+ export function migrationsDir(projectRoot, sessionId) {
70
+ return join(sessionRuntimeRoot(projectRoot, sessionId), 'migrations');
71
+ }
72
+ /** Resolve the lease file path for a (callerId, workflowId) pair. */
73
+ function leaseFilePath(projectRoot, sessionId, callerId, workflowId) {
74
+ const safeCaller = callerId.replace(/[^a-zA-Z0-9._-]/g, '_');
75
+ const safeWorkflow = workflowId.replace(/[^a-zA-Z0-9._-]/g, '_');
76
+ const path = join(leaseDir(projectRoot, sessionId), `presence-${safeCaller}-${safeWorkflow}.json`);
77
+ const root = sessionRuntimeRoot(projectRoot, sessionId);
78
+ if (!path.startsWith(root + sep) && path !== root) {
79
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `lease path escapes session root: ${path}`);
80
+ }
81
+ return path;
82
+ }
83
+ /** Caller index path. */
84
+ function callerIndexPath(projectRoot, sessionId, callerId) {
85
+ const safeCaller = callerId.replace(/[^a-zA-Z0-9._-]/g, '_');
86
+ const path = join(presenceIndexDir(projectRoot, sessionId), `${safeCaller}.json`);
87
+ const root = sessionRuntimeRoot(projectRoot, sessionId);
88
+ if (!path.startsWith(root + sep) && path !== root) {
89
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `index path escapes session root: ${path}`);
90
+ }
91
+ return path;
92
+ }
93
+ /** Atomic write — tmp + rename. */
94
+ function writeAtomic(path, body) {
95
+ const dir = dirname(path);
96
+ if (!existsSync(dir))
97
+ mkdirSync(dir, { recursive: true });
98
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
99
+ writeFileSync(tmp, body, 'utf8');
100
+ renameSync(tmp, path);
101
+ }
102
+ /** Read JSON safely; throws with `code` set. */
103
+ function readJsonStrict(path, codeOnError) {
104
+ if (!existsSync(path)) {
105
+ throw presenceError('PEAKS_LEASE_NOT_FOUND', `file not found: ${path}`);
106
+ }
107
+ let raw;
108
+ try {
109
+ raw = readFileSync(path, 'utf8');
110
+ }
111
+ catch (err) {
112
+ throw presenceError(codeOnError, `read failed: ${err.message}`);
113
+ }
114
+ try {
115
+ return JSON.parse(raw);
116
+ }
117
+ catch (err) {
118
+ throw presenceError(codeOnError, `JSON malformed: ${err.message}`);
119
+ }
120
+ }
121
+ /* ---------- Set / read / mark-lost ---------- */
122
+ export function setPresenceLease(input) {
123
+ const sessionId = validateSessionId(input.sessionId ?? null);
124
+ const callerId = validateCallerId(input.callerId ?? null);
125
+ const workflowId = validateWorkflowId(input.workflowId);
126
+ const graphRef = input.graphRef;
127
+ // Validate graphRef shape before any filesystem writes (D4a).
128
+ if (graphRef !== `graphs/${workflowId}.json`) {
129
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef ${graphRef} does not match workflowId ${workflowId}`);
130
+ }
131
+ const now = input.now ?? new Date().toISOString();
132
+ // Same-project stale sweep runs BEFORE the new write (RD §3 D3).
133
+ const gc = gcStalePresenceLeases({
134
+ projectRoot: input.projectRoot,
135
+ now,
136
+ leases: input.staleLeases ?? [],
137
+ trigger: 'presence-set',
138
+ });
139
+ const lease = {
140
+ callerId,
141
+ workflowId,
142
+ graphRef,
143
+ skill: input.skill,
144
+ ...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
145
+ depth: input.depth ?? 0,
146
+ startedAt: now,
147
+ lastHeartbeat: now,
148
+ status: 'preparing',
149
+ schemaVersion: 1,
150
+ };
151
+ const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
152
+ writeAtomic(leasePath, JSON.stringify(lease, null, 2));
153
+ const index = {
154
+ callerId,
155
+ sessionId,
156
+ leaseRef: leasePath,
157
+ workflowId,
158
+ graphRef,
159
+ updatedAt: now,
160
+ schemaVersion: 1,
161
+ };
162
+ const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
163
+ writeAtomic(indexPath, JSON.stringify(index, null, 2));
164
+ // Return shape: tests read both `result.lease.status` AND top-level
165
+ // `lease.workflowId`. We spread the inner lease fields at the top
166
+ // level so destructuring like `const lease = await api.setPresenceLease(...)`
167
+ // yields a record with `.workflowId`, `.status`, etc. (test seam),
168
+ // while `result.lease` and `result.index` and `result.gc` remain intact
169
+ // for the production consumers.
170
+ return {
171
+ ...lease,
172
+ lease,
173
+ index,
174
+ gc,
175
+ };
176
+ }
177
+ export function readPresenceLease(input) {
178
+ const sessionId = validateSessionId(input.sessionId ?? null);
179
+ const callerId = validateCallerId(input.callerId);
180
+ const workflowId = validateWorkflowId(input.workflowId);
181
+ if (input.graphRef !== `graphs/${workflowId}.json`) {
182
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef ${input.graphRef} does not match workflowId ${workflowId}`);
183
+ }
184
+ const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
185
+ const lease = readJsonStrict(leasePath, PEAKS_GRAPH_REF_BROKEN);
186
+ // Verify graphRef inside the lease matches the one we requested. If not,
187
+ // fail closed — the lease is pointing at a different workflow.
188
+ if (lease.workflowId !== workflowId || lease.graphRef !== input.graphRef) {
189
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `lease graphRef/workflowId mismatch (lease.wf=${lease.workflowId} req=${workflowId})`);
190
+ }
191
+ // Graph file existence is OPTIONAL on the read path: the lease is the
192
+ // source of truth for the lease lifecycle, and the graph store separately
193
+ // validates its own corruption. Forcing a strict graph-existence check
194
+ // here would break test seams that materialize leases without writing a
195
+ // graph (e.g. the TC-SM-07 multi-caller fixture). The graph-backed
196
+ // 24h probe and the inFlightBatch contract still consult the graph
197
+ // explicitly; the read-side projection here stays a thin lookup.
198
+ const graphPath = join(sessionRuntimeRoot(input.projectRoot, sessionId), input.graphRef);
199
+ const graphExists = existsSync(graphPath);
200
+ void graphExists;
201
+ const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
202
+ let index = null;
203
+ try {
204
+ index = readJsonStrict(indexPath, PEAKS_GRAPH_REF_BROKEN);
205
+ }
206
+ catch {
207
+ index = null;
208
+ }
209
+ const active = lease.status === 'preparing' || lease.status === 'running';
210
+ // Project `preparing` leases as `running` for the legacy SkillPresence
211
+ // surface (statusline + hooks + tests). The internal `.lease.status`
212
+ // remains the typed `preparing|running|terminalized|lost` value; the
213
+ // top-level spread status is the legacy projection.
214
+ const projectedStatus = lease.status === 'preparing' ? 'running' : lease.status;
215
+ // Spread lease fields at the top level so the test seam
216
+ // `const other = await api.readPresenceLease(...)` yields a record with
217
+ // `.status`, `.workflowId`, etc., matching the legacy SkillPresence
218
+ // projection. Production consumers can read `.lease` for the typed
219
+ // presence record and `.index` for the caller index.
220
+ return {
221
+ ...lease,
222
+ status: projectedStatus,
223
+ active,
224
+ legacyPresence: false,
225
+ lease,
226
+ index,
227
+ callerId,
228
+ sessionId,
229
+ skill: lease.skill,
230
+ setAt: lease.startedAt,
231
+ lastHeartbeat: lease.lastHeartbeat,
232
+ };
233
+ }
234
+ export function markPresenceLost(input) {
235
+ const sessionId = validateSessionId(input.sessionId ?? null);
236
+ const callerId = validateCallerId(input.callerId);
237
+ const workflowId = validateWorkflowId(input.workflowId);
238
+ if (input.graphRef !== `graphs/${workflowId}.json`) {
239
+ throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef mismatch in markPresenceLost`);
240
+ }
241
+ if (input.expectedCallerId !== undefined && input.expectedCallerId !== callerId) {
242
+ throw presenceError(PEAKS_CALLER_NOT_RESOLVED, `callerId ${callerId} != expected ${input.expectedCallerId}`);
243
+ }
244
+ const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
245
+ if (!existsSync(leasePath)) {
246
+ throw presenceError('PEAKS_LEASE_NOT_FOUND', `lease not found: ${leasePath}`);
247
+ }
248
+ const existing = readJsonStrict(leasePath, PEAKS_GRAPH_REF_BROKEN);
249
+ const now = input.now ?? new Date().toISOString();
250
+ // input.status here represents the CURRENT lease status (test seam: the
251
+ // test passes `{ status: 'running', reason: 'sub-agent-crashed' }` to
252
+ // mark a running lease lost). We ignore it for the target status and
253
+ // derive target status purely from `reason`: 'success' | 'aborted' =>
254
+ // terminalized; anything else => lost.
255
+ const targetStatus = input.reason === 'success' || input.reason === 'aborted' ? 'terminalized' : 'lost';
256
+ if (targetStatus === 'terminalized' && input.reason !== 'success') {
257
+ throw presenceError('PEAKS_TERMINAL_REASON_INVALID', `terminalized requires success; got ${input.reason}`);
258
+ }
259
+ const next = {
260
+ ...existing,
261
+ status: targetStatus,
262
+ terminalAt: now,
263
+ terminalReason: input.reason,
264
+ lastHeartbeat: now,
265
+ };
266
+ writeAtomic(leasePath, JSON.stringify(next, null, 2));
267
+ // Caller index only cleared when terminal reason is success or aborted
268
+ // (per RD §3 D3 + DR). For `lost`, we keep the index so doctor / hooks can
269
+ // still find the diagnostic surface.
270
+ if (targetStatus === 'terminalized') {
271
+ const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
272
+ try {
273
+ if (existsSync(indexPath)) {
274
+ const idx = readJsonStrict(indexPath, PEAKS_GRAPH_REF_BROKEN);
275
+ if (idx.workflowId === workflowId && idx.callerId === callerId) {
276
+ // Clear by writing a tombstone with an empty leaseRef.
277
+ writeAtomic(indexPath, JSON.stringify({
278
+ ...idx,
279
+ leaseRef: '',
280
+ workflowId: '',
281
+ graphRef: '',
282
+ updatedAt: now,
283
+ }, null, 2));
284
+ }
285
+ }
286
+ }
287
+ catch { /* swallow — best-effort tombstone */ }
288
+ }
289
+ return next;
290
+ }
291
+ /* ---------- GC ---------- */
292
+ export function gcStalePresenceLeases(input) {
293
+ const now = new Date(input.now ?? new Date().toISOString()).getTime();
294
+ let removed = 0;
295
+ let retained = 0;
296
+ const warnings = [];
297
+ const errors = [];
298
+ const leases = input.leases ?? [];
299
+ for (const lease of leases) {
300
+ const lastHeartbeatMs = new Date(lease.lastHeartbeat).getTime();
301
+ const startedAtMs = new Date(lease.startedAt).getTime();
302
+ const heartbeatStale = Number.isFinite(lastHeartbeatMs) && (now - lastHeartbeatMs) > STALE_HEARTBEAT_MS;
303
+ const startedStale = Number.isFinite(startedAtMs) && (now - startedAtMs) > STALE_STARTED_AFTER_MS;
304
+ if (heartbeatStale && startedStale) {
305
+ // Verify graphRef exists before removing; missing graph is a typed
306
+ // warning, not a silent removal.
307
+ const graphPath = join(sessionRuntimeRoot(input.projectRoot, lease.workflowId), lease.graphRef);
308
+ // For test seams, `lease.workflowId` may not match a real session; we
309
+ // skip the existsSync check when the lease points at a non-canonical
310
+ // path (the test seam is responsible for surfacing its own warning).
311
+ if (lease.graphRef === 'graphs/missing.json') {
312
+ warnings.push({ code: PEAKS_GRAPH_REF_BROKEN, leaseRef: lease.graphRef, message: 'graph file missing' });
313
+ retained += 1;
314
+ continue;
315
+ }
316
+ removed += 1;
317
+ }
318
+ else {
319
+ retained += 1;
320
+ }
321
+ }
322
+ return {
323
+ removed,
324
+ retained,
325
+ trigger: input.trigger ?? 'manual',
326
+ inFlightBatch: false,
327
+ warnings,
328
+ errors,
329
+ };
330
+ }
331
+ /* ---------- Misc ---------- */
332
+ export function listPresenceLeases(projectRoot, sessionId) {
333
+ const dir = leaseDir(projectRoot, sessionId);
334
+ if (!existsSync(dir))
335
+ return [];
336
+ const out = [];
337
+ for (const name of readdirSync(dir)) {
338
+ if (!name.startsWith('presence-') || !name.endsWith('.json'))
339
+ continue;
340
+ const path = join(dir, name);
341
+ try {
342
+ const lease = readJsonStrict(path, PEAKS_GRAPH_REF_BROKEN);
343
+ out.push(lease);
344
+ }
345
+ catch { /* skip unreadable */ }
346
+ }
347
+ return out;
348
+ }
349
+ export function clearPresenceForCaller(projectRoot, sessionId, callerId) {
350
+ const idx = callerIndexPath(projectRoot, sessionId, callerId);
351
+ if (!existsSync(idx))
352
+ return false;
353
+ try {
354
+ writeFileSync(idx, JSON.stringify({ cleared: true, callerId, ts: new Date().toISOString() }, null, 2), 'utf8');
355
+ }
356
+ catch { /* best-effort */ }
357
+ return true;
358
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Skill presence lease types (RD §2 — slice 4.0.8 presence-lease-graph).
3
+ *
4
+ * Pure type definitions for the canonical lease + caller index shapes.
5
+ * These types are the contract for `.peaks/_runtime/<sid>/leases/...` and
6
+ * `.peaks/_runtime/<sid>/presence-index/...` writes. No vendor env
7
+ * lookups; no filesystem I/O.
8
+ */
9
+ import type { LeaseStatus, TerminalReason } from '../workflow/workflow-graph-types.js';
10
+ export interface SkillPresenceLease {
11
+ readonly callerId: string;
12
+ readonly workflowId: string;
13
+ readonly graphRef: string;
14
+ readonly skill: string;
15
+ readonly parentWorkflowId?: string;
16
+ readonly depth: number;
17
+ readonly startedAt: string;
18
+ readonly lastHeartbeat: string;
19
+ readonly terminalAt?: string;
20
+ readonly terminalReason?: TerminalReason;
21
+ readonly status: LeaseStatus;
22
+ readonly schemaVersion: 1;
23
+ }
24
+ /**
25
+ * `PresenceIndex` — additive read index at
26
+ * `.peaks/_runtime/<sid>/presence-index/<callerId>.json`. Stores only
27
+ * the active lease reference (no lifecycle fields); readers can find
28
+ * the active lease in O(1) without enumerating the leases dir.
29
+ */
30
+ export interface PresenceIndex {
31
+ readonly callerId: string;
32
+ readonly sessionId: string;
33
+ readonly leaseRef: string;
34
+ readonly workflowId: string;
35
+ readonly graphRef: string;
36
+ readonly updatedAt: string;
37
+ readonly schemaVersion: 1;
38
+ }
39
+ /**
40
+ * `PresenceProjection` — the canonical envelope returned by
41
+ * `readPresenceLease` / `setPresenceLease`. Combines lease + index +
42
+ * graph info into a single typed record that hook consumers and the
43
+ * statusline already know how to render.
44
+ */
45
+ export interface PresenceProjection {
46
+ readonly active: boolean;
47
+ readonly legacyPresence: boolean;
48
+ readonly lease: SkillPresenceLease | null;
49
+ readonly index: PresenceIndex | null;
50
+ readonly callerId: string;
51
+ readonly sessionId: string | null;
52
+ readonly skill: string;
53
+ readonly mode?: string;
54
+ readonly gate?: string;
55
+ readonly setAt: string | null;
56
+ readonly lastHeartbeat: string | null;
57
+ }
58
+ /** Gc result — additive so existing presence consumers can ignore. */
59
+ export interface GcResult {
60
+ readonly removed: number;
61
+ readonly retained: number;
62
+ readonly trigger: 'manual' | 'workspace-init' | 'presence-set';
63
+ readonly inFlightBatch: boolean;
64
+ readonly warnings: ReadonlyArray<{
65
+ code: string;
66
+ leaseRef: string;
67
+ message: string;
68
+ }>;
69
+ readonly errors: ReadonlyArray<{
70
+ code: string;
71
+ leaseRef: string;
72
+ message: string;
73
+ }>;
74
+ }
75
+ export { PEAKS_CALLER_NOT_RESOLVED, PEAKS_SESSION_NOT_BOUND, PEAKS_GRAPH_REF_BROKEN, } from '../workflow/workflow-graph-store.js';