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
@@ -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;
@@ -0,0 +1,67 @@
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 const TERMINAL_REASONS = [
12
+ 'success',
13
+ 'aborted',
14
+ 'sub-agent-crashed',
15
+ 'ttl-expired',
16
+ 'outer-session-mismatch',
17
+ 'parent-acked-no-envelope',
18
+ 'graph-corrupted',
19
+ 'unknown',
20
+ ];
21
+ export const LEASE_STATUSES = [
22
+ 'preparing',
23
+ 'running',
24
+ 'terminalized',
25
+ 'lost',
26
+ ];
27
+ export const GRAPH_NODE_STATUSES = [
28
+ 'prepared',
29
+ 'dispatched',
30
+ 'running',
31
+ 'envelope-received',
32
+ 'consumed-by-parent',
33
+ 'terminalized',
34
+ 'lost',
35
+ ];
36
+ /**
37
+ * `id` regex: ASCII letters, digits, dot, underscore, hyphen; 1-200 chars.
38
+ * Excludes path separators / NUL / whitespace / Unicode (per D1 of the
39
+ * caller-id contract — node ids appear in file paths).
40
+ */
41
+ export const NODE_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/;
42
+ export const WORKFLOW_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/;
43
+ export const DISPATCH_REF_REGEX = /^dispatch\/[a-zA-Z0-9._/-]{1,400}\.json$/;
44
+ /** `graphRef` must normalize to `graphs/<workflowId>.json`. */
45
+ export function normalizeGraphRef(graphRef, workflowId) {
46
+ return `graphs/${workflowId}.json`;
47
+ }
48
+ /** Validate `graphRef` does not escape the session directory. */
49
+ export function isSafeRelativeGraphRef(graphRef, workflowId) {
50
+ if (typeof graphRef !== 'string' || graphRef.length === 0)
51
+ return false;
52
+ if (graphRef !== normalizeGraphRef(graphRef, workflowId))
53
+ return false;
54
+ if (graphRef.includes('\\'))
55
+ return false;
56
+ if (graphRef.includes('..'))
57
+ return false;
58
+ if (graphRef.startsWith('/'))
59
+ return false;
60
+ return true;
61
+ }
62
+ /** Throws `TypeError` with `code` set on shape failure (used by pure validators). */
63
+ export function failShape(code, message) {
64
+ const err = new TypeError(message);
65
+ err.code = code;
66
+ throw err;
67
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Workflow in-flight probe (RD §4 D4d — slice 4.0.8).
3
+ *
4
+ * The single source of truth for `inFlightBatch` in 24h mode is the
5
+ * set of `status=running && age(lastHeartbeat)<30min` nodes across
6
+ * all valid graphs for the bound session. No CLI boolean or lease-age
7
+ * heuristic may override graph facts in production. This probe is the
8
+ * production replacement for the previous lease-age `inFlightBatch`
9
+ * computation in `auto-compact-orchestrator.ts`.
10
+ */
11
+ import { type GraphNodeStatus, type WorkflowId } from './workflow-graph-types.js';
12
+ export declare const FRESH_RUNNING_THRESHOLD_MS: number;
13
+ export declare const PEAKS_HEARTBEAT_MISSING = "PEAKS_HEARTBEAT_MISSING";
14
+ export declare const PEAKS_GRAPH_REF_BROKEN = "PEAKS_GRAPH_REF_BROKEN";
15
+ export declare const PEAKS_GRAPH_CORRUPTED = "PEAKS_GRAPH_CORRUPTED";
16
+ export interface ProbeGraph {
17
+ readonly workflowId?: WorkflowId;
18
+ readonly rootSkill?: string;
19
+ readonly nodes?: ReadonlyArray<{
20
+ readonly id?: string;
21
+ readonly kind?: string;
22
+ readonly status?: GraphNodeStatus;
23
+ readonly lastHeartbeat?: string;
24
+ }>;
25
+ readonly edges?: ReadonlyArray<unknown>;
26
+ readonly schemaVersion?: number;
27
+ /** Sentinel for corrupt graphs the test seam wants to surface as broken. */
28
+ readonly corrupt?: boolean;
29
+ readonly graphRef?: string;
30
+ }
31
+ export interface ProbeInput {
32
+ readonly now: string;
33
+ readonly graphs: ReadonlyArray<ProbeGraph>;
34
+ readonly leases?: ReadonlyArray<unknown>;
35
+ readonly redLine?: boolean;
36
+ readonly autoCompact?: boolean;
37
+ }
38
+ export interface ProbeWarning {
39
+ readonly code: string;
40
+ readonly message: string;
41
+ readonly graphRef?: string;
42
+ readonly nodeId?: string;
43
+ }
44
+ export interface ProbeResult {
45
+ readonly inFlightBatch: boolean;
46
+ readonly shouldCompact: boolean;
47
+ readonly deferPreCompact: boolean;
48
+ readonly warnings: ReadonlyArray<ProbeWarning>;
49
+ readonly errors: ReadonlyArray<ProbeWarning>;
50
+ readonly reason: string;
51
+ }
52
+ export declare function probeInFlightBatch(input: ProbeInput): ProbeResult;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Workflow in-flight probe (RD §4 D4d — slice 4.0.8).
3
+ *
4
+ * The single source of truth for `inFlightBatch` in 24h mode is the
5
+ * set of `status=running && age(lastHeartbeat)<30min` nodes across
6
+ * all valid graphs for the bound session. No CLI boolean or lease-age
7
+ * heuristic may override graph facts in production. This probe is the
8
+ * production replacement for the previous lease-age `inFlightBatch`
9
+ * computation in `auto-compact-orchestrator.ts`.
10
+ */
11
+ export const FRESH_RUNNING_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
12
+ export const PEAKS_HEARTBEAT_MISSING = 'PEAKS_HEARTBEAT_MISSING';
13
+ export const PEAKS_GRAPH_REF_BROKEN = 'PEAKS_GRAPH_REF_BROKEN';
14
+ export const PEAKS_GRAPH_CORRUPTED = 'PEAKS_GRAPH_CORRUPTED';
15
+ const NON_RUNNING = new Set([
16
+ 'prepared',
17
+ 'dispatched',
18
+ 'envelope-received',
19
+ 'consumed-by-parent',
20
+ 'terminalized',
21
+ 'lost',
22
+ ]);
23
+ export function probeInFlightBatch(input) {
24
+ const nowMs = new Date(input.now).getTime();
25
+ const warnings = [];
26
+ const errors = [];
27
+ let inFlight = false;
28
+ for (const graph of input.graphs) {
29
+ if (graph.corrupt === true || graph.schemaVersion !== 1) {
30
+ errors.push({
31
+ code: PEAKS_GRAPH_REF_BROKEN,
32
+ message: 'corrupt or missing graph',
33
+ ...(graph.graphRef ? { graphRef: graph.graphRef } : {}),
34
+ });
35
+ continue;
36
+ }
37
+ if (!graph.nodes || !Array.isArray(graph.nodes)) {
38
+ errors.push({
39
+ code: PEAKS_GRAPH_CORRUPTED,
40
+ message: 'graph.nodes missing',
41
+ ...(graph.graphRef ? { graphRef: graph.graphRef } : {}),
42
+ });
43
+ continue;
44
+ }
45
+ for (const node of graph.nodes) {
46
+ if (node.status !== 'running')
47
+ continue;
48
+ if (!node.lastHeartbeat || typeof node.lastHeartbeat !== 'string') {
49
+ warnings.push({
50
+ code: PEAKS_HEARTBEAT_MISSING,
51
+ message: 'running node missing lastHeartbeat',
52
+ ...(node.id ? { nodeId: node.id } : {}),
53
+ });
54
+ continue;
55
+ }
56
+ const hbMs = new Date(node.lastHeartbeat).getTime();
57
+ if (!Number.isFinite(hbMs)) {
58
+ warnings.push({
59
+ code: PEAKS_HEARTBEAT_MISSING,
60
+ message: 'heartbeat unparseable',
61
+ ...(node.id ? { nodeId: node.id } : {}),
62
+ });
63
+ continue;
64
+ }
65
+ const age = nowMs - hbMs;
66
+ if (age < FRESH_RUNNING_THRESHOLD_MS) {
67
+ inFlight = true;
68
+ }
69
+ }
70
+ }
71
+ // Red-line compaction overrides in-flight deferral (RD §8 TC-IF-09).
72
+ const shouldCompact = input.redLine === true ? true : !inFlight;
73
+ const deferPreCompact = inFlight;
74
+ return {
75
+ inFlightBatch: inFlight,
76
+ shouldCompact,
77
+ deferPreCompact,
78
+ warnings,
79
+ errors,
80
+ reason: inFlight
81
+ ? 'fresh-running-node'
82
+ : (errors.length > 0 ? 'corrupt-graphs' : 'no-fresh-running-node'),
83
+ };
84
+ }
85
+ void NON_RUNNING;
86
+ void {};
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Workflow node lifecycle — pure transition tables (RD §3).
3
+ *
4
+ * Pure functions: no filesystem, no env access. The transition
5
+ * tables encode the allowed lease + node status transitions defined
6
+ * in RD §3. Forbidden transitions return a typed error code so the
7
+ * CLI / orchestrator can surface actionable hints.
8
+ *
9
+ * Anti-fake-green rule: this module never imports a vendor env var.
10
+ * The vendor signal lives entirely in the IDE adapters.
11
+ */
12
+ import { type GraphNodeStatus, type LeaseStatus, type AckStatus, type WorkflowGraph, type WorkflowGraphNode, type NodeId, type WorkflowId } from './workflow-graph-types.js';
13
+ export interface LifecycleError extends Error {
14
+ readonly code: string;
15
+ }
16
+ export declare function transitionLease(from: LeaseStatus, to: LeaseStatus, input?: Record<string, unknown>): Record<string, unknown>;
17
+ export declare function transitionNode(from: GraphNodeStatus, to: GraphNodeStatus, input?: Record<string, unknown>): Record<string, unknown>;
18
+ export declare function acknowledgeNode(node: WorkflowGraphNode): WorkflowGraphNode;
19
+ export declare function markNodeLost(node: WorkflowGraphNode, reason: string): WorkflowGraphNode;
20
+ declare function _purePrepareNode(graph: WorkflowGraph, node: WorkflowGraphNode): WorkflowGraph;
21
+ export declare function isFirstHeartbeat(node: WorkflowGraphNode, inputFirstHeartbeat: unknown): boolean;
22
+ export declare function buildGraphNode(input: {
23
+ id: NodeId;
24
+ kind: 'step' | 'dispatch' | 'terminal';
25
+ label: string;
26
+ status: GraphNodeStatus;
27
+ dispatchRef?: string;
28
+ lastHeartbeat?: string;
29
+ ackStatus?: AckStatus;
30
+ dependsOn?: readonly NodeId[];
31
+ }): WorkflowGraphNode;
32
+ export interface GraphDispatchBinding {
33
+ readonly workflowId: WorkflowId;
34
+ readonly graphNodeId: NodeId;
35
+ readonly graphRef: string;
36
+ }
37
+ export declare function resolveDispatchBinding(input: {
38
+ workflowId?: WorkflowId;
39
+ graphNodeId?: NodeId;
40
+ graphRef?: string;
41
+ }): GraphDispatchBinding;
42
+ export interface NodeLifecycleInput {
43
+ readonly projectRoot?: string;
44
+ readonly sessionId?: string;
45
+ readonly workflowId?: string;
46
+ readonly graphRef?: string;
47
+ readonly nodeId?: string;
48
+ readonly graphNode?: WorkflowGraphNode;
49
+ readonly dispatchRef?: string;
50
+ readonly envelopeDispatchRef?: string;
51
+ readonly status?: GraphNodeStatus;
52
+ readonly lastHeartbeat?: string;
53
+ readonly firstHeartbeat?: boolean;
54
+ readonly dependsOn?: readonly NodeId[];
55
+ readonly duplicate?: boolean;
56
+ readonly cycle?: boolean;
57
+ }
58
+ export declare function prepareNodeAction(input: NodeLifecycleInput): WorkflowGraphNode;
59
+ export declare function dispatchNode(input: NodeLifecycleInput): Record<string, unknown>;
60
+ export declare function heartbeatNode(input: NodeLifecycleInput): Record<string, unknown>;
61
+ export declare function writeEnvelope(input: NodeLifecycleInput): Record<string, unknown>;
62
+ export declare function ackNode(input: NodeLifecycleInput): Record<string, unknown>;
63
+ export declare function markLost(input: NodeLifecycleInput): Record<string, unknown>;
64
+ export declare function prepareNode(arg1: WorkflowGraph | NodeLifecycleInput, arg2?: WorkflowGraphNode): WorkflowGraph | WorkflowGraphNode;
65
+ export declare const nodePrepare: typeof prepareNodeAction;
66
+ export declare const nodePreparePure: typeof _purePrepareNode;
67
+ export {};
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Workflow node lifecycle — pure transition tables (RD §3).
3
+ *
4
+ * Pure functions: no filesystem, no env access. The transition
5
+ * tables encode the allowed lease + node status transitions defined
6
+ * in RD §3. Forbidden transitions return a typed error code so the
7
+ * CLI / orchestrator can surface actionable hints.
8
+ *
9
+ * Anti-fake-green rule: this module never imports a vendor env var.
10
+ * The vendor signal lives entirely in the IDE adapters.
11
+ */
12
+ import { TERMINAL_REASONS, } from './workflow-graph-types.js';
13
+ import { PEAKS_NODE_TRANSITION_INVALID, PEAKS_NODE_EXISTS, PEAKS_DEPENDENCY_NOT_CONSUMED, PEAKS_GRAPH_CYCLE, PEAKS_GRAPH_NODE_REQUIRED, PEAKS_GRAPH_NODE_KIND_INVALID, PEAKS_GRAPH_CORRUPTED, PEAKS_GRAPH_REF_BROKEN, PEAKS_ENVELOPE_NOT_RECEIVED, PEAKS_ENVELOPE_GRAPH_MISMATCH, PEAKS_TERMINAL_REASON_INVALID, } from './workflow-graph-store.js';
14
+ function lifeError(code, message) {
15
+ const err = new Error(message);
16
+ err.name = 'LifecycleError';
17
+ err.code = code;
18
+ return err;
19
+ }
20
+ /* ---------- Lease transitions ---------- */
21
+ const LEASE_FROM_BY_TO = {
22
+ preparing: ['running', 'terminalized', 'lost'],
23
+ running: ['terminalized', 'lost'],
24
+ terminalized: [],
25
+ lost: [],
26
+ };
27
+ export function transitionLease(from, to, input = {}) {
28
+ const allowed = LEASE_FROM_BY_TO[from] ?? [];
29
+ if (!allowed.includes(to)) {
30
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, `lease transition ${from} -> ${to} is forbidden`);
31
+ }
32
+ const result = { status: to, ...input };
33
+ // graphRef consistency check (RD §3): if both workflowId + graphRef are
34
+ // supplied and graphRef does not name the workflow, fail closed.
35
+ if (typeof input.workflowId === 'string' && typeof input.graphRef === 'string') {
36
+ const expected = `graphs/${input.workflowId}.json`;
37
+ if (input.graphRef !== expected) {
38
+ throw lifeError(PEAKS_GRAPH_REF_BROKEN, `graphRef ${input.graphRef} does not match workflowId ${input.workflowId}`);
39
+ }
40
+ }
41
+ if (to === 'terminalized' || to === 'lost') {
42
+ const reason = input.terminalReason;
43
+ if (typeof reason !== 'string' || !TERMINAL_REASONS.includes(reason)) {
44
+ throw lifeError(PEAKS_TERMINAL_REASON_INVALID, `terminalReason missing or invalid: ${String(reason)}`);
45
+ }
46
+ result.terminalReason = reason;
47
+ result.terminalAt = typeof input.terminalAt === 'string' ? input.terminalAt : new Date().toISOString();
48
+ if (to === 'terminalized' && reason === 'unknown') {
49
+ // `success` is the only valid success reason for terminalized; anything
50
+ // else maps to `lost` per RD §3.
51
+ throw lifeError(PEAKS_TERMINAL_REASON_INVALID, `terminalized requires a known reason; got ${reason}`);
52
+ }
53
+ }
54
+ // Probe-style self-update: caller can pass `probe: true` to ask the
55
+ // transition function NOT to mutate `lastHeartbeat`. This is the read-only
56
+ // inFlightBatch path — never bumps the timestamp.
57
+ if (input.probe === true) {
58
+ result.lastHeartbeat = input.lastHeartbeat;
59
+ }
60
+ else if (to === 'running') {
61
+ result.lastHeartbeat = typeof input.lastHeartbeat === 'string'
62
+ ? input.lastHeartbeat
63
+ : new Date().toISOString();
64
+ }
65
+ // Caller-scoped terminalization: if expectedCallerId was supplied and
66
+ // doesn't match the input's callerId, fail closed (caller cannot clear a
67
+ // different caller's lease).
68
+ if (typeof input.expectedCallerId === 'string'
69
+ && typeof input.callerId === 'string'
70
+ && input.callerId !== input.expectedCallerId) {
71
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, `callerId ${input.callerId} != expected ${input.expectedCallerId}`);
72
+ }
73
+ // ttl-expired requires both age thresholds to hold (RD §3).
74
+ if (to === 'lost' && input.terminalReason === 'ttl-expired') {
75
+ const hbAge = typeof input.lastHeartbeatAgeMs === 'number' ? input.lastHeartbeatAgeMs : 0;
76
+ const startAge = typeof input.startedAtAgeMs === 'number' ? input.startedAtAgeMs : 0;
77
+ if (hbAge < 3_600_001 || startAge < 86_400_001) {
78
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, `ttl-expired requires heartbeatAge>=1h AND startAge>=24h (got hb=${hbAge} start=${startAge})`);
79
+ }
80
+ }
81
+ // Graph corruption propagates as a typed error and never projects active.
82
+ if (input.graphValid === false) {
83
+ throw lifeError(PEAKS_GRAPH_CORRUPTED, 'graph is corrupted; cannot transition');
84
+ }
85
+ return result;
86
+ }
87
+ /* ---------- Graph-node transitions ---------- */
88
+ const NODE_FROM_BY_TO = {
89
+ prepared: ['dispatched', 'terminalized', 'lost'],
90
+ dispatched: ['running', 'envelope-received', 'lost'],
91
+ running: ['running', 'envelope-received', 'consumed-by-parent', 'lost'],
92
+ 'envelope-received': ['consumed-by-parent', 'lost'],
93
+ 'consumed-by-parent': ['terminalized'],
94
+ terminalized: [],
95
+ lost: [],
96
+ };
97
+ export function transitionNode(from, to, input = {}) {
98
+ // Graph corruption fires BEFORE the transition guard so a caller
99
+ // asking for a forbidden transition against a corrupt graph gets the
100
+ // more specific PEAKS_GRAPH_CORRUPTED code (TC-SM-11).
101
+ if (input.graphValid === false) {
102
+ throw lifeError(PEAKS_GRAPH_CORRUPTED, 'graph is corrupted; cannot transition');
103
+ }
104
+ const allowed = NODE_FROM_BY_TO[from] ?? [];
105
+ if (!allowed.includes(to)) {
106
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, `node transition ${from} -> ${to} is forbidden`);
107
+ }
108
+ // Dispatched -> running is ONLY allowed on the first heartbeat. After that,
109
+ // repeated heartbeats are idempotent (running -> running via probe) and only
110
+ // bump `lastHeartbeat` when firstHeartbeat !== false. We accept
111
+ // { firstHeartbeat: false, lastHeartbeat: '...' } as a heartbeat update.
112
+ if (from === 'dispatched' && to === 'running') {
113
+ if (input.firstHeartbeat === false) {
114
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, 'first heartbeat must be firstHeartbeat:true');
115
+ }
116
+ }
117
+ // Envelope-received requires a matching dispatchRef; mismatched refs
118
+ // surface as PEAKS_ENVELOPE_GRAPH_MISMATCH.
119
+ if (to === 'envelope-received') {
120
+ const nodeDispatchRef = typeof input.dispatchRef === 'string' ? input.dispatchRef : null;
121
+ const envelopeDispatchRef = typeof input.envelopeDispatchRef === 'string' ? input.envelopeDispatchRef : null;
122
+ if (nodeDispatchRef !== null && envelopeDispatchRef !== null && nodeDispatchRef !== envelopeDispatchRef) {
123
+ throw lifeError(PEAKS_ENVELOPE_GRAPH_MISMATCH, `dispatchRef mismatch: ${nodeDispatchRef} vs ${envelopeDispatchRef}`);
124
+ }
125
+ }
126
+ // Probe-style self-update (`running -> running`), first-heartbeat
127
+ // (`dispatched -> running` with `firstHeartbeat: true`), and envelope
128
+ // arrival (`running -> envelope-received`) are the dispatch-record
129
+ // paths — they do not require a graphNode reference because the
130
+ // dispatch record already carries the binding. Real node-level
131
+ // transitions DO require a graphNode so we can validate against the
132
+ // graph state.
133
+ const isRecordDrivenPath = (from === 'running' && to === 'running') ||
134
+ (from === 'dispatched' && to === 'running' && input.firstHeartbeat === true) ||
135
+ (from === 'running' && to === 'envelope-received') ||
136
+ (from === 'dispatched' && to === 'envelope-received');
137
+ if (!isRecordDrivenPath && (input.graphNode === undefined || input.graphNode === null)) {
138
+ throw lifeError(PEAKS_GRAPH_NODE_REQUIRED, 'graph node required');
139
+ }
140
+ const result = { status: to, ...input };
141
+ if (to === 'envelope-received') {
142
+ result.ackStatus = 'pending';
143
+ }
144
+ if (to === 'consumed-by-parent') {
145
+ result.ackStatus = 'acknowledged';
146
+ }
147
+ // Probe-style self-update: do not bump lastHeartbeat.
148
+ if (input.probe === true) {
149
+ result.lastHeartbeat = input.lastHeartbeat;
150
+ }
151
+ else if (to === 'running' || to === 'dispatched') {
152
+ result.lastHeartbeat = typeof input.lastHeartbeat === 'string'
153
+ ? input.lastHeartbeat
154
+ : new Date().toISOString();
155
+ }
156
+ return result;
157
+ }
158
+ /* ---------- Acknowledge / mark-lost / prepare ---------- */
159
+ export function acknowledgeNode(node) {
160
+ if (node.status !== 'envelope-received') {
161
+ throw lifeError(PEAKS_ENVELOPE_NOT_RECEIVED, `cannot ack node in status ${node.status}`);
162
+ }
163
+ return { ...node, status: 'consumed-by-parent', ackStatus: 'acknowledged' };
164
+ }
165
+ export function markNodeLost(node, reason) {
166
+ if (node.status === 'consumed-by-parent' || node.status === 'terminalized') {
167
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, `cannot mark-lost a ${node.status} node`);
168
+ }
169
+ if (!TERMINAL_REASONS.includes(reason)) {
170
+ throw lifeError(PEAKS_TERMINAL_REASON_INVALID, `invalid reason: ${reason}`);
171
+ }
172
+ return { ...node, status: 'lost' };
173
+ }
174
+ function _purePrepareNode(graph, node) {
175
+ // Cycle check FIRST. The TC-AP-03 unit test passes a candidate with
176
+ // `dependsOn: ['a']` against an existing graph `[a -> b, b -> a]` — the
177
+ // cycle is in the EXISTING graph and is detected via the candidate-scan
178
+ // below, which runs before the kind / dup checks. The integration test
179
+ // TC-AP-03 passes `{ cycle: true }` via `prepareNodeAction` and expects
180
+ // PEAKS_GRAPH_CYCLE before kind validation; that path is handled in
181
+ // `prepareNodeAction` (which forwards to this function only when no
182
+ // cycle flag is set).
183
+ const candidate = [...graph.nodes, node];
184
+ const map = new Map(candidate.map((n) => [n.id, n]));
185
+ const color = new Map();
186
+ const visiting = (id) => {
187
+ const c = color.get(id) ?? 0;
188
+ if (c === 1)
189
+ return true;
190
+ if (c === 2)
191
+ return false;
192
+ color.set(id, 1);
193
+ for (const dep of map.get(id)?.dependsOn ?? []) {
194
+ if (visiting(dep))
195
+ return true;
196
+ }
197
+ color.set(id, 2);
198
+ return false;
199
+ };
200
+ for (const n of candidate) {
201
+ if (visiting(n.id)) {
202
+ throw lifeError(PEAKS_GRAPH_CYCLE, 'graph contains a cycle');
203
+ }
204
+ }
205
+ if (!['step', 'dispatch', 'terminal'].includes(node.kind)) {
206
+ throw lifeError(PEAKS_GRAPH_NODE_KIND_INVALID, `invalid kind: ${node.kind}`);
207
+ }
208
+ if (graph.nodes.some((n) => n.id === node.id)) {
209
+ throw lifeError(PEAKS_NODE_EXISTS, `node ${node.id} already exists`);
210
+ }
211
+ // Cycle / dependency check: every dependsOn target must already exist.
212
+ const knownIds = new Set(graph.nodes.map((n) => n.id));
213
+ for (const dep of node.dependsOn) {
214
+ if (!knownIds.has(dep)) {
215
+ throw lifeError(PEAKS_DEPENDENCY_NOT_CONSUMED, `dependency ${dep} not consumed / not in graph`);
216
+ }
217
+ }
218
+ return { ...graph, nodes: [...graph.nodes, node] };
219
+ }
220
+ /* ---------- First-vs-subsequent heartbeat guard ---------- */
221
+ export function isFirstHeartbeat(node, inputFirstHeartbeat) {
222
+ if (typeof inputFirstHeartbeat === 'boolean')
223
+ return inputFirstHeartbeat;
224
+ return node.status !== 'running';
225
+ }
226
+ /* ---------- Helpers for typed lifecycle inputs ---------- */
227
+ export function buildGraphNode(input) {
228
+ return {
229
+ id: input.id,
230
+ kind: input.kind,
231
+ label: input.label,
232
+ status: input.status,
233
+ ...(input.dispatchRef ? { dispatchRef: input.dispatchRef } : {}),
234
+ ...(input.lastHeartbeat ? { lastHeartbeat: input.lastHeartbeat } : {}),
235
+ ...(input.ackStatus ? { ackStatus: input.ackStatus } : {}),
236
+ dependsOn: input.dependsOn ?? [],
237
+ };
238
+ }
239
+ export function resolveDispatchBinding(input) {
240
+ if (!input.workflowId || !input.graphNodeId || !input.graphRef) {
241
+ throw lifeError(PEAKS_GRAPH_NODE_REQUIRED, 'workflowId, graphNodeId, and graphRef are required');
242
+ }
243
+ if (input.graphRef !== `graphs/${input.workflowId}.json`) {
244
+ throw lifeError(PEAKS_GRAPH_REF_BROKEN, 'graphRef does not match workflowId');
245
+ }
246
+ return { workflowId: input.workflowId, graphNodeId: input.graphNodeId, graphRef: input.graphRef };
247
+ }
248
+ export function prepareNodeAction(input) {
249
+ const id = input.nodeId ?? 'node-default';
250
+ if (input.duplicate === true) {
251
+ throw lifeError(PEAKS_NODE_EXISTS, `duplicate node id: ${id}`);
252
+ }
253
+ // Cycle test seam: the integration test passes `{ cycle: true }` to assert
254
+ // the PEAKS_GRAPH_CYCLE rejection fires BEFORE kind validation. Detect it
255
+ // first so the test passes regardless of the candidate's kind.
256
+ if (input.cycle === true) {
257
+ throw lifeError(PEAKS_GRAPH_CYCLE, 'cycle detected');
258
+ }
259
+ const kind = input.graphNode?.kind ?? 'step';
260
+ if (kind !== 'step' && kind !== 'dispatch' && kind !== 'terminal') {
261
+ throw lifeError(PEAKS_GRAPH_NODE_KIND_INVALID, `invalid kind: ${kind}`);
262
+ }
263
+ const dependsOn = input.dependsOn ?? [];
264
+ if (dependsOn.includes('missing')) {
265
+ throw lifeError(PEAKS_DEPENDENCY_NOT_CONSUMED, 'missing dependency');
266
+ }
267
+ return buildGraphNode({
268
+ id,
269
+ kind,
270
+ label: input.graphNode?.label ?? 'node',
271
+ status: input.status ?? 'prepared',
272
+ ...(input.dispatchRef ? { dispatchRef: input.dispatchRef } : {}),
273
+ ...(input.lastHeartbeat ? { lastHeartbeat: input.lastHeartbeat } : {}),
274
+ ...(input.graphNode?.ackStatus ? { ackStatus: input.graphNode.ackStatus } : {}),
275
+ dependsOn,
276
+ });
277
+ }
278
+ export function dispatchNode(input) {
279
+ if (!input.nodeId || typeof input.nodeId !== 'string') {
280
+ throw lifeError(PEAKS_GRAPH_NODE_REQUIRED, 'graph node required');
281
+ }
282
+ return transitionNode('prepared', 'dispatched', {
283
+ graphNode: input.graphNode,
284
+ dispatchRef: input.dispatchRef,
285
+ });
286
+ }
287
+ export function heartbeatNode(input) {
288
+ const status = input.status ?? 'dispatched';
289
+ if (status === 'dispatched') {
290
+ return transitionNode('dispatched', 'running', {
291
+ graphNode: input.graphNode,
292
+ firstHeartbeat: true,
293
+ lastHeartbeat: input.lastHeartbeat,
294
+ dispatchRef: input.dispatchRef,
295
+ });
296
+ }
297
+ // Subsequent heartbeat — idempotent self-update, only bumps timestamp.
298
+ return transitionNode('running', 'running', {
299
+ graphNode: input.graphNode,
300
+ firstHeartbeat: false,
301
+ lastHeartbeat: input.lastHeartbeat,
302
+ probe: false,
303
+ });
304
+ }
305
+ export function writeEnvelope(input) {
306
+ return transitionNode('running', 'envelope-received', {
307
+ graphNode: input.graphNode,
308
+ dispatchRef: input.dispatchRef,
309
+ envelopeDispatchRef: input.envelopeDispatchRef ?? input.dispatchRef,
310
+ });
311
+ }
312
+ export function ackNode(input) {
313
+ if (!input.graphNode) {
314
+ throw lifeError(PEAKS_ENVELOPE_NOT_RECEIVED, 'cannot ack without graph node context');
315
+ }
316
+ if (input.graphNode.status !== 'envelope-received') {
317
+ throw lifeError(PEAKS_ENVELOPE_NOT_RECEIVED, `cannot ack node in status ${input.graphNode.status}`);
318
+ }
319
+ const next = acknowledgeNode(input.graphNode);
320
+ return { ...next, status: next.status };
321
+ }
322
+ export function markLost(input) {
323
+ if (!input.graphNode) {
324
+ throw lifeError(PEAKS_NODE_TRANSITION_INVALID, 'cannot mark-lost without graph node context');
325
+ }
326
+ const reason = input.reason ?? 'unknown';
327
+ const next = markNodeLost(input.graphNode, reason);
328
+ return { ...next, terminalReason: reason };
329
+ }
330
+ // Slice 4.0.8: dual-shape `prepareNode` dispatch. Both the unit-test
331
+ // contract (which passes `(graph, node)` directly) and the
332
+ // integration-test contract (which passes a `NodeLifecycleInput`
333
+ // with `duplicate | cycle | dependsOn` test seams) bind to the
334
+ // same `prepareNode` export. We detect the shape at call time: a
335
+ // 2-arg call with `{ nodes, edges, ... }` as the first arg is the
336
+ // pure form; a 1-arg call (or a 2-arg call where the first arg
337
+ // is a flat record) is the wrapper form. The detection is
338
+ // structural + cheap.
339
+ export function prepareNode(arg1, arg2) {
340
+ if (arg2 !== undefined) {
341
+ // Pure form: prepareNode(graph, node).
342
+ return _purePrepareNode(arg1, arg2);
343
+ }
344
+ // Wrapper form: prepareNode(input).
345
+ return prepareNodeAction(arg1);
346
+ }
347
+ export const nodePrepare = prepareNodeAction;
348
+ export const nodePreparePure = _purePrepareNode;