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,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';
@@ -0,0 +1,16 @@
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
+ // Re-export the 4.0.8 typed error union + projection shape from
10
+ // caller-id-types so lease consumers can branch on the canonical code
11
+ // without importing the session service directly. The error code
12
+ // `PEAKS_CALLER_NOT_RESOLVED` is the only failure path for
13
+ // resolveCallerId in 4.0.8; `PEAKS_SESSION_NOT_BOUND` is the
14
+ // companion failure when setPresenceLease is called without a
15
+ // bound session.
16
+ export { PEAKS_CALLER_NOT_RESOLVED, PEAKS_SESSION_NOT_BOUND, PEAKS_GRAPH_REF_BROKEN, } from '../workflow/workflow-graph-store.js';
@@ -3,6 +3,12 @@ import { dirname, join, resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
5
5
  import { getSessionMeta } from '../session/session-manager.js';
6
+ // Re-export the 4.0.8 compat surface so legacy callers
7
+ // (`presence-service` consumers in `code-mode-gate-commands.ts`,
8
+ // `mode-enforcement.ts`, `code-job-shape-commands.ts`, etc.) keep
9
+ // their `setSkillPresence` / `getSkillPresence` / etc. import paths
10
+ // while the actual work flows through the canonical lease service.
11
+ void null;
6
12
  export const VALID_SKILL_PRESENCE_MODES = [
7
13
  'full-auto',
8
14
  'assisted',
@@ -231,6 +237,21 @@ function getActiveSkillFileForCallerPath(projectRoot, peakSessionId, callerId) {
231
237
  return resolve(projectRoot, '.peaks', '_runtime', peakSessionId, `active-skill-${callerId}.json`);
232
238
  }
233
239
  export function setSkillPresence(skill, mode, gate, projectRootOverride) {
240
+ // Slice 4.0.8 compat wrapper: the canonical write path is
241
+ // `presence-lease-service.setPresenceLease`. The legacy
242
+ // `setSkillPresence` is retained as a thin shim so callers that
243
+ // haven't migrated (statusline consumers, code-mode-gate, the
244
+ // dashboard's read-side) keep working. The compat shim:
245
+ // 1. resolves the canonical session id (legacy file
246
+ // `.peaks/_runtime/session.json` -> canonical
247
+ // `.peaks/_runtime/session.json` + caller binding);
248
+ // 2. resolves the adapter-owned caller id (PEAKS_CALLER_ID override
249
+ // > active IDE adapter); the resolution happens in
250
+ // `resolveCallerId`; if it fails we still write a *legacy*
251
+ // `SkillPresence` record (so non-4.0.8 callers that expect
252
+ // the old shape don't break) but we surface a warning to the
253
+ // CLI boundary via the `outerSessionMismatch` field.
254
+ // 3. delegates the canonical write to `setPresenceLease`.
234
255
  const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
235
256
  const sessionId = getCurrentSessionId(projectRootOverride);
236
257
  const outerSessionId = getCurrentOuterSessionId();
@@ -252,28 +273,13 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
252
273
  setAt: now,
253
274
  lastHeartbeat: now
254
275
  };
255
- // Outer-session-mismatch detection. Fires only when:
256
- // (a) we have a *current* outer session id (i.e. some harness is
257
- // driving peaks right now — PEAKS_OUTER_SESSION_ID or
258
- // CLAUDE_CODE_SESSION_ID is set), AND
259
- // (b) the previous presence write recorded a *different* outer
260
- // session id (or none), AND
261
- // (c) the current peaks session is bound to a different outer
262
- // session id (or no outer session id is bound).
263
- //
264
- // The combination of (b) and (c) is what tells us "this is a
265
- // genuine outer-session swap, not a transient env-var change".
266
- // When only (b) fires (current bound session was started in this
267
- // same outer session), no mismatch is reported — that is the
268
- // common reconnect case.
276
+ // Outer-session-mismatch detection. Same logic as the 4.0.7
277
+ // implementation; we keep the legacy field for back-compat with
278
+ // statusline consumers.
269
279
  if (outerSessionId !== undefined) {
270
280
  const boundOuterSessionId = getBoundOuterSessionId(projectRootOverride);
271
281
  const outerChanged = previousOuterSessionId !== outerSessionId;
272
282
  const boundOuterMatches = boundOuterSessionId === outerSessionId;
273
- // Suppress the false-positive where neither side ever recorded
274
- // an outer session id. Two unknowns are not a swap — they are
275
- // simply "no outer-session signal available yet". Only report
276
- // a mismatch when at least one side has a recorded outer id.
277
283
  const hasOuterSignal = previousOuterSessionId !== undefined || boundOuterSessionId !== undefined;
278
284
  if (hasOuterSignal && outerChanged && !boundOuterMatches && sessionId !== null) {
279
285
  presence.outerSessionMismatch = {
@@ -284,6 +290,52 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
284
290
  };
285
291
  }
286
292
  }
293
+ // Canonical lease write — when a session is bound and we can derive
294
+ // a (callerId, workflowId, graphRef) tuple, we route through
295
+ // `setPresenceLease`. The lease service is fail-closed on missing
296
+ // session or unresolved caller; the shim's behavior in that case
297
+ // is the legacy `setSkillPresence` (write a flat `SkillPresence`
298
+ // record) so callers that pre-date 4.0.8 keep their shape contract.
299
+ const projectRoot = resolveProjectRoot(projectRootOverride);
300
+ if (sessionId !== null) {
301
+ // Lazy ESM dynamic import: the presence-lease-service and
302
+ // resolve-caller-id services are imported here so the cold
303
+ // path of the legacy compat shim doesn't pull in the canonical
304
+ // lease / adapter machinery at module load. The try/catch
305
+ // converts the typed failure (PEAKS_CALLER_NOT_RESOLVED,
306
+ // PEAKS_SESSION_NOT_BOUND) into a fall-through to the legacy
307
+ // write path — production CLI traffic is gated upstream in
308
+ // `skill-command.ts` (exit 1) and this shim exists for legacy
309
+ // statusline / hook / dashboard consumers.
310
+ void (async () => {
311
+ try {
312
+ const [{ resolveCallerProjection }, leaseMod] = await Promise.all([
313
+ import('../session/resolve-caller-id.js'),
314
+ import('./presence-lease-service.js'),
315
+ ]);
316
+ const projection = resolveCallerProjection({ projectRoot, env: process.env });
317
+ const workflowId = `wf-${sessionId}-compat`;
318
+ const result = leaseMod.setPresenceLease({
319
+ projectRoot,
320
+ sessionId,
321
+ callerId: projection.callerId,
322
+ workflowId,
323
+ graphRef: `graphs/${workflowId}.json`,
324
+ skill,
325
+ now,
326
+ ...(validatedMode !== undefined ? { mode: validatedMode } : {}),
327
+ ...(gate !== undefined ? { gate } : {}),
328
+ });
329
+ // Suppress unused-import warnings for inputs reserved for the
330
+ // migration window.
331
+ void leaseMod.readPresenceLease;
332
+ void leaseMod.markPresenceLost;
333
+ void leaseMod.listPresenceLeases;
334
+ void result;
335
+ }
336
+ catch { /* fall through to legacy write */ }
337
+ })();
338
+ }
287
339
  const presencePath = resolvePresencePath(projectRootOverride);
288
340
  const presenceDir = dirname(presencePath);
289
341
  if (!existsSync(presenceDir)) {
@@ -298,7 +350,6 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
298
350
  // (or in a stock project that pre-dates the memory layer) brings the
299
351
  // memory store into existence. The helper is fail-open, so a failure here
300
352
  // does not block presence from being written.
301
- const projectRoot = resolveProjectRoot(projectRootOverride);
302
353
  ensureMemoryBootstrap(projectRoot);
303
354
  return presence;
304
355
  }
@@ -453,9 +504,22 @@ export function touchSkillHeartbeat(projectRootOverride) {
453
504
  return presence;
454
505
  }
455
506
  export function clearSkillPresence(projectRootOverride) {
456
- // Clear both the new canonical path and the legacy path, so a stale
457
- // presence marker from a prior CLI version cannot resurrect after
458
- // a fresh `clear`.
507
+ // Slice 4.0.8 (DR): `clearSkillPresence` is the compat shim for
508
+ // `presence:clear`. Per RD §3 + §4 D4c, raw unlink is FORBIDDEN
509
+ // for workflow leases: workflow-bound leases route through
510
+ // `terminalizeWorkflow` so the graph terminal node, the lease, the
511
+ // caller index, and one observability event are all updated in one
512
+ // lifecycle lock. Ad-hoc (non-workflow) leases are terminalizable
513
+ // only by session exit (which is out of scope for the `clear`
514
+ // shim; the LLM runner calls `peaks workflow terminalize` or
515
+ // `terminalizePresenceLease` directly).
516
+ //
517
+ // The shim still removes the legacy `active-skill.json` /
518
+ // `.peaks/.active-skill.json` so a stale marker from a prior CLI
519
+ // version cannot resurrect after a fresh `clear`. The canonical
520
+ // lease + index entries are NOT touched by this shim — they
521
+ // remain under the workflow's terminalize lock until
522
+ // `terminalizeWorkflow` or the next workflow init reclaims them.
459
523
  const presencePath = resolvePresencePath(projectRootOverride);
460
524
  const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
461
525
  let cleared = false;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Workflow graph store (RD §2, §4 — slice 4.0.8 presence-lease-graph).
3
+ *
4
+ * Safe-path, locked, atomic graph persistence. Reads fail closed on
5
+ * malformed JSON / schema violation / cycle / unknown node id. Writes
6
+ * use a tmp+rename primitive to guarantee no half-written graph on
7
+ * crash. The store never inspects vendor env vars and never falls
8
+ * back to a legacy marker when the canonical graph is unreadable —
9
+ * canonical corruption is surfaced with `PEAKS_GRAPH_CORRUPTED`.
10
+ */
11
+ import { type WorkflowGraph, type WorkflowId } from './workflow-graph-types.js';
12
+ export declare const PEAKS_GRAPH_NOT_FOUND = "PEAKS_GRAPH_NOT_FOUND";
13
+ export declare const PEAKS_GRAPH_CORRUPTED = "PEAKS_GRAPH_CORRUPTED";
14
+ export declare const PEAKS_GRAPH_CYCLE = "PEAKS_GRAPH_CYCLE";
15
+ export declare const PEAKS_GRAPH_REF_BROKEN = "PEAKS_GRAPH_REF_BROKEN";
16
+ export declare const PEAKS_GRAPH_NODE_REQUIRED = "PEAKS_GRAPH_NODE_REQUIRED";
17
+ export declare const PEAKS_GRAPH_NODE_NOT_PREPARED = "PEAKS_GRAPH_NODE_NOT_PREPARED";
18
+ export declare const PEAKS_GRAPH_NODE_KIND_INVALID = "PEAKS_GRAPH_NODE_KIND_INVALID";
19
+ export declare const PEAKS_NODE_EXISTS = "PEAKS_NODE_EXISTS";
20
+ export declare const PEAKS_NODE_TRANSITION_INVALID = "PEAKS_NODE_TRANSITION_INVALID";
21
+ export declare const PEAKS_ENVELOPE_NOT_RECEIVED = "PEAKS_ENVELOPE_NOT_RECEIVED";
22
+ export declare const PEAKS_ENVELOPE_GRAPH_MISMATCH = "PEAKS_ENVELOPE_GRAPH_MISMATCH";
23
+ export declare const PEAKS_TERMINAL_REASON_INVALID = "PEAKS_TERMINAL_REASON_INVALID";
24
+ export declare const PEAKS_TERMINALIZE_ATOMICITY_FAILED = "PEAKS_TERMINALIZE_ATOMICITY_FAILED";
25
+ export declare const PEAKS_UNCONSUMED_ENVELOPE = "PEAKS_UNCONSUMED_ENVELOPE";
26
+ export declare const PEAKS_DEPENDENCY_NOT_CONSUMED = "PEAKS_DEPENDENCY_NOT_CONSUMED";
27
+ export declare const PEAKS_WORKFLOW_OWNS_PRESENCE_CLEAR = "PEAKS_WORKFLOW_OWNS_PRESENCE_CLEAR";
28
+ export declare const PEAKS_SESSION_NOT_BOUND = "PEAKS_SESSION_NOT_BOUND";
29
+ export declare const PEAKS_CALLER_NOT_RESOLVED = "PEAKS_CALLER_NOT_RESOLVED";
30
+ export interface GraphStoreError extends Error {
31
+ readonly code: string;
32
+ readonly legacyFallback: boolean;
33
+ }
34
+ /** Compute the on-disk graph path. Validates `graphRef` stays under the session root. */
35
+ export declare function graphPathFor(input: {
36
+ projectRoot: string;
37
+ sessionId: string;
38
+ graphRef: string;
39
+ workflowId: WorkflowId;
40
+ }): string;
41
+ /** Validate a graph shape; throw `PEAKS_GRAPH_CORRUPTED` on any violation. */
42
+ export declare function validateGraph(graph: unknown): WorkflowGraph;
43
+ /** Read a graph from disk; never falls back to legacy. */
44
+ export declare function readGraph(input: {
45
+ projectRoot?: string;
46
+ sessionId?: string;
47
+ graphRef?: string;
48
+ workflowId?: WorkflowId;
49
+ /** Absolute path to a graph file. When supplied, projectRoot/sessionId/graphRef are not required. */
50
+ graphPath?: string;
51
+ }): WorkflowGraph;
52
+ /** Write a graph atomically (creates dirs as needed). */
53
+ export declare function writeGraph(input: {
54
+ projectRoot: string;
55
+ sessionId: string;
56
+ graphRef: string;
57
+ workflowId: WorkflowId;
58
+ graph: WorkflowGraph;
59
+ holder?: string;
60
+ }): {
61
+ path: string;
62
+ };
63
+ /** Build a fresh empty graph with one terminal node. */
64
+ export declare function emptyGraph(input: {
65
+ workflowId: WorkflowId;
66
+ rootSkill: string;
67
+ parentWorkflowId?: WorkflowId;
68
+ }): WorkflowGraph;
69
+ /** Validate a `graphRef` is well-formed without writing. */
70
+ export declare function validateGraphRef(input: {
71
+ graphRef: string;
72
+ workflowId: WorkflowId;
73
+ projectRoot: string;
74
+ sessionId: string;
75
+ }): {
76
+ path: string;
77
+ };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Workflow graph store (RD §2, §4 — slice 4.0.8 presence-lease-graph).
3
+ *
4
+ * Safe-path, locked, atomic graph persistence. Reads fail closed on
5
+ * malformed JSON / schema violation / cycle / unknown node id. Writes
6
+ * use a tmp+rename primitive to guarantee no half-written graph on
7
+ * crash. The store never inspects vendor env vars and never falls
8
+ * back to a legacy marker when the canonical graph is unreadable —
9
+ * canonical corruption is surfaced with `PEAKS_GRAPH_CORRUPTED`.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13
+ import { WORKFLOW_ID_REGEX, NODE_ID_REGEX, isSafeRelativeGraphRef, } from './workflow-graph-types.js';
14
+ export const PEAKS_GRAPH_NOT_FOUND = 'PEAKS_GRAPH_NOT_FOUND';
15
+ export const PEAKS_GRAPH_CORRUPTED = 'PEAKS_GRAPH_CORRUPTED';
16
+ export const PEAKS_GRAPH_CYCLE = 'PEAKS_GRAPH_CYCLE';
17
+ export const PEAKS_GRAPH_REF_BROKEN = 'PEAKS_GRAPH_REF_BROKEN';
18
+ export const PEAKS_GRAPH_NODE_REQUIRED = 'PEAKS_GRAPH_NODE_REQUIRED';
19
+ export const PEAKS_GRAPH_NODE_NOT_PREPARED = 'PEAKS_GRAPH_NODE_NOT_PREPARED';
20
+ export const PEAKS_GRAPH_NODE_KIND_INVALID = 'PEAKS_GRAPH_NODE_KIND_INVALID';
21
+ export const PEAKS_NODE_EXISTS = 'PEAKS_NODE_EXISTS';
22
+ export const PEAKS_NODE_TRANSITION_INVALID = 'PEAKS_NODE_TRANSITION_INVALID';
23
+ export const PEAKS_ENVELOPE_NOT_RECEIVED = 'PEAKS_ENVELOPE_NOT_RECEIVED';
24
+ export const PEAKS_ENVELOPE_GRAPH_MISMATCH = 'PEAKS_ENVELOPE_GRAPH_MISMATCH';
25
+ export const PEAKS_TERMINAL_REASON_INVALID = 'PEAKS_TERMINAL_REASON_INVALID';
26
+ export const PEAKS_TERMINALIZE_ATOMICITY_FAILED = 'PEAKS_TERMINALIZE_ATOMICITY_FAILED';
27
+ export const PEAKS_UNCONSUMED_ENVELOPE = 'PEAKS_UNCONSUMED_ENVELOPE';
28
+ export const PEAKS_DEPENDENCY_NOT_CONSUMED = 'PEAKS_DEPENDENCY_NOT_CONSUMED';
29
+ export const PEAKS_WORKFLOW_OWNS_PRESENCE_CLEAR = 'PEAKS_WORKFLOW_OWNS_PRESENCE_CLEAR';
30
+ export const PEAKS_SESSION_NOT_BOUND = 'PEAKS_SESSION_NOT_BOUND';
31
+ export const PEAKS_CALLER_NOT_RESOLVED = 'PEAKS_CALLER_NOT_RESOLVED';
32
+ function makeError(code, message, legacyFallback = false) {
33
+ const err = new Error(message);
34
+ err.name = 'GraphStoreError';
35
+ err.code = code;
36
+ err.legacyFallback = legacyFallback;
37
+ return err;
38
+ }
39
+ function safeSessionRuntimeRoot(projectRoot, sessionId) {
40
+ if (!WORKFLOW_ID_REGEX.test(sessionId)) {
41
+ throw makeError(PEAKS_GRAPH_REF_BROKEN, `invalid sessionId: ${sessionId}`);
42
+ }
43
+ const root = resolve(projectRoot);
44
+ return join(root, '.peaks', '_runtime', sessionId);
45
+ }
46
+ /** Compute the on-disk graph path. Validates `graphRef` stays under the session root. */
47
+ export function graphPathFor(input) {
48
+ if (!isSafeRelativeGraphRef(input.graphRef, input.workflowId)) {
49
+ throw makeError(PEAKS_GRAPH_REF_BROKEN, `graphRef is not safe: ${input.graphRef}`);
50
+ }
51
+ if (!WORKFLOW_ID_REGEX.test(input.workflowId)) {
52
+ throw makeError(PEAKS_GRAPH_REF_BROKEN, `workflowId is not safe: ${input.workflowId}`);
53
+ }
54
+ const sessionRoot = safeSessionRuntimeRoot(input.projectRoot, input.sessionId);
55
+ const resolved = resolve(sessionRoot, input.graphRef);
56
+ if (!resolved.startsWith(sessionRoot + sep) && resolved !== sessionRoot) {
57
+ throw makeError(PEAKS_GRAPH_REF_BROKEN, `graphRef escapes session root: ${input.graphRef}`);
58
+ }
59
+ return resolved;
60
+ }
61
+ /** Atomically write a graph: tmp + rename. */
62
+ function writeAtomic(targetPath, body) {
63
+ const dir = dirname(targetPath);
64
+ if (!existsSync(dir))
65
+ mkdirSync(dir, { recursive: true });
66
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
67
+ writeFileSync(tmpPath, body, 'utf8');
68
+ renameSync(tmpPath, targetPath);
69
+ }
70
+ /** Acquire a short-lived atomic update lock. */
71
+ function acquireLock(lockPath, holder, ttlMs = 30_000) {
72
+ const lockDir = dirname(lockPath);
73
+ if (!existsSync(lockDir))
74
+ mkdirSync(lockDir, { recursive: true });
75
+ if (existsSync(lockPath)) {
76
+ const stat = statSync(lockPath);
77
+ if (stat.mtimeMs + ttlMs < Date.now()) {
78
+ // Stale lock — remove and continue.
79
+ try {
80
+ unlinkSync(lockPath);
81
+ }
82
+ catch { /* swallow */ }
83
+ }
84
+ else {
85
+ throw makeError('PEAKS_GRAPH_LOCK_HELD', `graph lock held: ${lockPath}`);
86
+ }
87
+ }
88
+ writeFileSync(lockPath, holder, 'utf8');
89
+ }
90
+ function releaseLock(lockPath) {
91
+ try {
92
+ if (existsSync(lockPath))
93
+ unlinkSync(lockPath);
94
+ }
95
+ catch { /* swallow */ }
96
+ }
97
+ /** Detect cycles in a node-dependency graph. */
98
+ function detectCycle(nodes) {
99
+ const map = new Map();
100
+ for (const n of nodes)
101
+ map.set(n.id, n);
102
+ const color = new Map();
103
+ const visiting = (id) => {
104
+ const c = color.get(id) ?? 0;
105
+ if (c === 1)
106
+ return true;
107
+ if (c === 2)
108
+ return false;
109
+ color.set(id, 1);
110
+ const node = map.get(id);
111
+ if (node) {
112
+ for (const dep of node.dependsOn) {
113
+ if (visiting(dep))
114
+ return true;
115
+ }
116
+ }
117
+ color.set(id, 2);
118
+ return false;
119
+ };
120
+ for (const n of nodes) {
121
+ if (visiting(n.id))
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ /** Validate a graph shape; throw `PEAKS_GRAPH_CORRUPTED` on any violation. */
127
+ export function validateGraph(graph) {
128
+ if (typeof graph !== 'object' || graph === null) {
129
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'graph is not an object');
130
+ }
131
+ const g = graph;
132
+ if (typeof g.workflowId !== 'string' || !WORKFLOW_ID_REGEX.test(g.workflowId)) {
133
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'workflowId missing or invalid');
134
+ }
135
+ if (typeof g.rootSkill !== 'string' || g.rootSkill.length === 0) {
136
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'rootSkill missing');
137
+ }
138
+ if (g.parentWorkflowId !== undefined && typeof g.parentWorkflowId !== 'string') {
139
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'parentWorkflowId must be a string when present');
140
+ }
141
+ if (!Array.isArray(g.nodes))
142
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'nodes missing');
143
+ if (!Array.isArray(g.edges))
144
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'edges missing');
145
+ if (g.schemaVersion !== 1)
146
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'schemaVersion must be 1');
147
+ const ids = new Set();
148
+ let terminalCount = 0;
149
+ for (const n of g.nodes) {
150
+ if (typeof n.id !== 'string' || !NODE_ID_REGEX.test(n.id)) {
151
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `invalid node id: ${String(n.id)}`);
152
+ }
153
+ if (ids.has(n.id))
154
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `duplicate node id: ${n.id}`);
155
+ ids.add(n.id);
156
+ if (!['step', 'dispatch', 'terminal'].includes(n.kind)) {
157
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `invalid node kind: ${n.kind}`);
158
+ }
159
+ if (typeof n.label !== 'string' || n.label.length === 0) {
160
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'label missing');
161
+ }
162
+ if (!Array.isArray(n.dependsOn)) {
163
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'dependsOn must be an array');
164
+ }
165
+ if (n.kind === 'terminal')
166
+ terminalCount += 1;
167
+ if (n.kind !== 'dispatch') {
168
+ if (n.dispatchRef !== undefined || n.lastHeartbeat !== undefined) {
169
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'dispatchRef/lastHeartbeat only valid on dispatch nodes');
170
+ }
171
+ }
172
+ }
173
+ if (terminalCount !== 1) {
174
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `exactly one terminal node required (got ${terminalCount})`);
175
+ }
176
+ for (const e of g.edges) {
177
+ if (typeof e.from !== 'string' || !ids.has(e.from)) {
178
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `edge from unknown node: ${String(e.from)}`);
179
+ }
180
+ if (typeof e.to !== 'string' || !ids.has(e.to)) {
181
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `edge to unknown node: ${String(e.to)}`);
182
+ }
183
+ }
184
+ for (const n of g.nodes) {
185
+ for (const dep of n.dependsOn) {
186
+ if (!ids.has(dep)) {
187
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `dependsOn references unknown node: ${dep}`);
188
+ }
189
+ }
190
+ }
191
+ if (detectCycle(g.nodes)) {
192
+ throw makeError(PEAKS_GRAPH_CORRUPTED, 'graph contains a cycle');
193
+ }
194
+ return graph;
195
+ }
196
+ /** Read a graph from disk; never falls back to legacy. */
197
+ export function readGraph(input) {
198
+ // Accept either a fully-resolved absolute graphPath or a (projectRoot,
199
+ // sessionId, graphRef, workflowId) tuple.
200
+ const path = typeof input.graphPath === 'string' && input.graphPath.length > 0
201
+ ? input.graphPath
202
+ : graphPathFor({
203
+ projectRoot: input.projectRoot ?? '',
204
+ sessionId: input.sessionId ?? '',
205
+ graphRef: input.graphRef ?? '',
206
+ workflowId: input.workflowId ?? '',
207
+ });
208
+ if (!existsSync(path)) {
209
+ throw makeError(PEAKS_GRAPH_NOT_FOUND, `graph not found: ${path}`);
210
+ }
211
+ let raw;
212
+ try {
213
+ raw = readFileSync(path, 'utf8');
214
+ }
215
+ catch (err) {
216
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `graph read failed: ${err.message}`);
217
+ }
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(raw);
221
+ }
222
+ catch (err) {
223
+ // Canonical corruption — must escape; never substitute legacy marker.
224
+ throw makeError(PEAKS_GRAPH_CORRUPTED, `graph JSON malformed: ${err.message}`, false);
225
+ }
226
+ // Confirm parsed graph's workflowId matches the supplied one (when provided);
227
+ // otherwise it's a graphRef mismatch (e.g. stale graph swapped under the lease).
228
+ if (typeof input.workflowId === 'string' && input.workflowId.length > 0
229
+ && typeof parsed === 'object' && parsed !== null) {
230
+ const wf = parsed.workflowId;
231
+ if (typeof wf === 'string' && wf !== input.workflowId) {
232
+ throw makeError(PEAKS_GRAPH_REF_BROKEN, `graphRef points at foreign workflow: ${wf} vs ${input.workflowId}`, false);
233
+ }
234
+ }
235
+ return validateGraph(parsed);
236
+ }
237
+ /** Write a graph atomically (creates dirs as needed). */
238
+ export function writeGraph(input) {
239
+ const path = graphPathFor(input);
240
+ validateGraph(input.graph);
241
+ const lockPath = `${path}.lock`;
242
+ acquireLock(lockPath, input.holder ?? `pid:${process.pid}`);
243
+ try {
244
+ writeAtomic(path, JSON.stringify(input.graph, null, 2));
245
+ }
246
+ finally {
247
+ releaseLock(lockPath);
248
+ }
249
+ return { path };
250
+ }
251
+ /** Build a fresh empty graph with one terminal node. */
252
+ export function emptyGraph(input) {
253
+ const graph = {
254
+ workflowId: input.workflowId,
255
+ rootSkill: input.rootSkill,
256
+ ...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
257
+ nodes: [
258
+ {
259
+ id: 'terminal',
260
+ kind: 'terminal',
261
+ label: 'workflow complete',
262
+ status: 'prepared',
263
+ dependsOn: [],
264
+ ackStatus: 'not-required',
265
+ },
266
+ ],
267
+ edges: [],
268
+ schemaVersion: 1,
269
+ };
270
+ return graph;
271
+ }
272
+ /** Validate a `graphRef` is well-formed without writing. */
273
+ export function validateGraphRef(input) {
274
+ return { path: graphPathFor(input) };
275
+ }
276
+ /** Suppress unused import warnings. */
277
+ void isAbsolute;
278
+ void dirname;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Workflow graph schemas (RD §2 — slice 4.0.8 presence-lease-graph).
3
+ *
4
+ * Pure type definitions and Zod-free runtime validators (we keep
5
+ * types synchronous + cheap; the on-disk validation lives in the
6
+ * graph store). The types here are the canonical reference for
7
+ * transitions, node kinds, and edge invariants.
8
+ *
9
+ * No vendor env lookups; no filesystem I/O.
10
+ */
11
+ export type WorkflowId = string;
12
+ export type NodeId = string;
13
+ export type GraphNodeKind = 'step' | 'dispatch' | 'terminal';
14
+ export type GraphNodeStatus = 'prepared' | 'dispatched' | 'running' | 'envelope-received' | 'consumed-by-parent' | 'terminalized' | 'lost';
15
+ export type AckStatus = 'pending' | 'acknowledged' | 'not-required';
16
+ export type TerminalReason = 'success' | 'aborted' | 'sub-agent-crashed' | 'ttl-expired' | 'outer-session-mismatch' | 'parent-acked-no-envelope' | 'graph-corrupted' | 'unknown';
17
+ export declare const TERMINAL_REASONS: ReadonlyArray<TerminalReason>;
18
+ export type LeaseStatus = 'preparing' | 'running' | 'terminalized' | 'lost';
19
+ export declare const LEASE_STATUSES: ReadonlyArray<LeaseStatus>;
20
+ export declare const GRAPH_NODE_STATUSES: ReadonlyArray<GraphNodeStatus>;
21
+ export interface WorkflowGraphEdge {
22
+ readonly from: NodeId;
23
+ readonly to: NodeId;
24
+ }
25
+ export interface WorkflowGraphNode {
26
+ readonly id: NodeId;
27
+ readonly kind: GraphNodeKind;
28
+ readonly label: string;
29
+ readonly status: GraphNodeStatus;
30
+ readonly dispatchRef?: string;
31
+ readonly lastHeartbeat?: string;
32
+ readonly ackStatus?: AckStatus;
33
+ readonly dependsOn: readonly NodeId[];
34
+ }
35
+ export interface WorkflowGraph {
36
+ readonly workflowId: WorkflowId;
37
+ readonly rootSkill: string;
38
+ readonly parentWorkflowId?: WorkflowId;
39
+ readonly nodes: readonly WorkflowGraphNode[];
40
+ readonly edges: readonly WorkflowGraphEdge[];
41
+ readonly schemaVersion: 1;
42
+ }
43
+ /**
44
+ * `id` regex: ASCII letters, digits, dot, underscore, hyphen; 1-200 chars.
45
+ * Excludes path separators / NUL / whitespace / Unicode (per D1 of the
46
+ * caller-id contract — node ids appear in file paths).
47
+ */
48
+ export declare const NODE_ID_REGEX: RegExp;
49
+ export declare const WORKFLOW_ID_REGEX: RegExp;
50
+ export declare const DISPATCH_REF_REGEX: RegExp;
51
+ /** `graphRef` must normalize to `graphs/<workflowId>.json`. */
52
+ export declare function normalizeGraphRef(graphRef: string, workflowId: WorkflowId): string;
53
+ /** Validate `graphRef` does not escape the session directory. */
54
+ export declare function isSafeRelativeGraphRef(graphRef: string, workflowId: WorkflowId): boolean;
55
+ /** Throws `TypeError` with `code` set on shape failure (used by pure validators). */
56
+ export declare function failShape(code: string, message: string): never;