peaks-loop 4.0.7 → 4.0.9

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 (156) hide show
  1. package/CHANGELOG.md +58 -288
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/baseline-commands.d.ts +3 -0
  4. package/dist/cli/commands/baseline-commands.js +146 -0
  5. package/dist/cli/commands/code-job-shape-commands.js +8 -11
  6. package/dist/cli/commands/code-mode-gate-commands.js +1 -43
  7. package/dist/cli/commands/code-runtime-commands.js +36 -14
  8. package/dist/cli/commands/core/skill-command.js +75 -13
  9. package/dist/cli/commands/core/standards-command.js +5 -50
  10. package/dist/cli/commands/dispatch-commands.d.ts +20 -0
  11. package/dist/cli/commands/dispatch-commands.js +39 -38
  12. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  13. package/dist/cli/commands/heartbeat-commands.d.ts +21 -0
  14. package/dist/cli/commands/heartbeat-commands.js +40 -0
  15. package/dist/cli/commands/hook-handle.js +5 -2
  16. package/dist/cli/commands/hooks-commands.js +9 -26
  17. package/dist/cli/commands/loop-eval-commands.js +5 -2
  18. package/dist/cli/commands/statusline-commands.js +1 -1
  19. package/dist/cli/commands/sub-agent-commands.js +0 -2
  20. package/dist/cli/commands/sub-agent-shared.d.ts +7 -9
  21. package/dist/cli/commands/workflow-commands.js +8 -0
  22. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +63 -0
  23. package/dist/cli/commands/workflow-lifecycle-commands.js +302 -0
  24. package/dist/cli/commands/workspace/init-command.js +19 -0
  25. package/dist/services/audit/backing-detector.d.ts +2 -19
  26. package/dist/services/audit/backing-detector.js +7 -66
  27. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +25 -9
  28. package/dist/services/audit/enforcers/active-skill-resolver.js +83 -21
  29. package/dist/services/audit/enforcers/lint-audit-regression.js +1 -10
  30. package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
  31. package/dist/services/capability-audit-service/cross-check.js +11 -0
  32. package/dist/services/capability-audit-service/index.d.ts +3 -0
  33. package/dist/services/capability-audit-service/index.js +2 -0
  34. package/dist/services/capability-audit-service/runner.d.ts +27 -0
  35. package/dist/services/capability-audit-service/runner.js +40 -0
  36. package/dist/services/capability-audit-service/staleness.d.ts +1 -0
  37. package/dist/services/capability-audit-service/staleness.js +7 -0
  38. package/dist/services/capability-audit-service/types.d.ts +24 -0
  39. package/dist/services/capability-audit-service/types.js +1 -0
  40. package/dist/services/capability-baseline/store.d.ts +33 -0
  41. package/dist/services/capability-baseline/store.js +80 -0
  42. package/dist/services/capability-baseline/types.d.ts +45 -0
  43. package/dist/services/capability-baseline/types.js +5 -0
  44. package/dist/services/capability-baseline/validator.d.ts +19 -0
  45. package/dist/services/capability-baseline/validator.js +38 -0
  46. package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
  47. package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
  48. package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
  49. package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
  50. package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
  51. package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
  52. package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
  53. package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
  54. package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
  55. package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
  56. package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
  57. package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
  58. package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
  59. package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
  60. package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
  61. package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
  62. package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
  63. package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
  64. package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
  65. package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
  66. package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
  67. package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
  68. package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
  69. package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
  70. package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
  71. package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
  72. package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
  73. package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
  74. package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
  75. package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
  76. package/dist/services/capability-guard-runner/diff.d.ts +2 -0
  77. package/dist/services/capability-guard-runner/diff.js +7 -0
  78. package/dist/services/capability-guard-runner/runner.d.ts +9 -0
  79. package/dist/services/capability-guard-runner/runner.js +22 -0
  80. package/dist/services/capability-guard-runner/types.d.ts +39 -0
  81. package/dist/services/capability-guard-runner/types.js +1 -0
  82. package/dist/services/code/auto-compact-orchestrator.d.ts +35 -2
  83. package/dist/services/code/auto-compact-orchestrator.js +29 -9
  84. package/dist/services/context/build-dispatch-system-prompt.js +0 -18
  85. package/dist/services/dispatch/dispatch-record-writer.d.ts +25 -1
  86. package/dist/services/dispatch/dispatch-record-writer.js +85 -15
  87. package/dist/services/dispatch/test-tool-detection.d.ts +0 -14
  88. package/dist/services/dispatch/test-tool-detection.js +0 -22
  89. package/dist/services/doctor/doctor-service/checks/skill-presence.d.ts +8 -0
  90. package/dist/services/doctor/doctor-service/checks/skill-presence.js +9 -1
  91. package/dist/services/evolution/evolution-types.d.ts +16 -16
  92. package/dist/services/final-review/final-review-service.d.ts +8 -0
  93. package/dist/services/final-review/final-review-service.js +14 -0
  94. package/dist/services/final-review/final-review-types.d.ts +1 -1
  95. package/dist/services/hooks/presence-marker-detector.d.ts +6 -0
  96. package/dist/services/hooks/presence-marker-detector.js +48 -0
  97. package/dist/services/ide/adapters/claude-code-adapter.js +20 -0
  98. package/dist/services/ide/adapters/codex-adapter.js +20 -1
  99. package/dist/services/ide/adapters/cursor-adapter.js +21 -1
  100. package/dist/services/ide/adapters/hermes-adapter.js +20 -1
  101. package/dist/services/ide/adapters/openclaw-adapter.js +20 -1
  102. package/dist/services/ide/adapters/qoder-adapter.js +20 -1
  103. package/dist/services/ide/adapters/tongyi-lingma-adapter.js +20 -1
  104. package/dist/services/ide/adapters/trae-adapter.js +21 -1
  105. package/dist/services/ide/adapters/zcode-adapter.js +19 -0
  106. package/dist/services/ide/ide-types.d.ts +15 -0
  107. package/dist/services/job/job-progress-store.d.ts +2 -2
  108. package/dist/services/job/job-types.d.ts +26 -26
  109. package/dist/services/observability/observability-service.d.ts +4 -4
  110. package/dist/services/scan/archetype-service.js +2 -52
  111. package/dist/services/sediment/json-schema.d.ts +4 -4
  112. package/dist/services/session/caller-binding-service.d.ts +21 -0
  113. package/dist/services/session/caller-binding-service.js +35 -0
  114. package/dist/services/session/caller-id-types.d.ts +62 -13
  115. package/dist/services/session/caller-id-types.js +25 -13
  116. package/dist/services/session/index.d.ts +4 -2
  117. package/dist/services/session/index.js +8 -2
  118. package/dist/services/session/platform-fallbacks.d.ts +25 -17
  119. package/dist/services/session/platform-fallbacks.js +26 -28
  120. package/dist/services/session/resolve-caller-id.d.ts +64 -29
  121. package/dist/services/session/resolve-caller-id.js +122 -48
  122. package/dist/services/session/session-manager.d.ts +0 -29
  123. package/dist/services/session/session-manager.js +0 -33
  124. package/dist/services/session/session-resume-service.d.ts +4 -0
  125. package/dist/services/session/session-resume-service.js +4 -0
  126. package/dist/services/skills/hooks-settings-service.d.ts +0 -41
  127. package/dist/services/skills/hooks-settings-service.js +5 -61
  128. package/dist/services/skills/presence-lease-service.d.ts +73 -0
  129. package/dist/services/skills/presence-lease-service.js +358 -0
  130. package/dist/services/skills/presence-lease-types.d.ts +75 -0
  131. package/dist/services/skills/presence-lease-types.js +16 -0
  132. package/dist/services/skills/skill-presence-service.js +86 -22
  133. package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
  134. package/dist/services/skills/skill-statusline-renderer.js +312 -100
  135. package/dist/services/slice/slice-check-service.js +0 -59
  136. package/dist/services/standards/project-standards-service.js +0 -44
  137. package/dist/services/workflow/workflow-graph-store.d.ts +77 -0
  138. package/dist/services/workflow/workflow-graph-store.js +278 -0
  139. package/dist/services/workflow/workflow-graph-types.d.ts +56 -0
  140. package/dist/services/workflow/workflow-graph-types.js +67 -0
  141. package/dist/services/workflow/workflow-inflight-probe.d.ts +52 -0
  142. package/dist/services/workflow/workflow-inflight-probe.js +86 -0
  143. package/dist/services/workflow/workflow-node-lifecycle.d.ts +67 -0
  144. package/dist/services/workflow/workflow-node-lifecycle.js +348 -0
  145. package/dist/services/workflow/workflow-presence-lifecycle.d.ts +58 -0
  146. package/dist/services/workflow/workflow-presence-lifecycle.js +196 -0
  147. package/dist/services/workspace/reconcile-service.d.ts +31 -0
  148. package/dist/services/workspace/reconcile-service.js +132 -1
  149. package/package.json +5 -4
  150. package/skills/peaks-code/SKILL.md +5 -43
  151. package/skills/peaks-code/references/completion-handoff.md +1 -1
  152. package/skills/peaks-code/references/skill-presence-and-title.md +1 -1
  153. package/dist/cli/commands/sub-agent-exec-command.d.ts +0 -25
  154. package/dist/cli/commands/sub-agent-exec-command.js +0 -132
  155. package/dist/services/standards/find-skills-integration.d.ts +0 -59
  156. package/dist/services/standards/find-skills-integration.js +0 -225
@@ -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;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Workflow presence lifecycle — atomic terminalization coordination
3
+ * (RD §3 D4c + §4 — slice 4.0.8 presence-lease-graph).
4
+ *
5
+ * Workflow init atomically creates a graph + canonical lease + caller
6
+ * index under one lifecycle lock. Terminalize atomically updates the
7
+ * terminal graph node, the lease's terminal fields, the caller
8
+ * index, and emits exactly one observability event. Both transitions
9
+ * are pure orchestrators — they delegate atomic writes to the graph
10
+ * store and lease service.
11
+ */
12
+ import { type WorkflowGraph, type WorkflowId, type TerminalReason } from './workflow-graph-types.js';
13
+ import type { SkillPresenceLease, PresenceIndex } from '../skills/presence-lease-types.js';
14
+ export interface InitWorkflowInput {
15
+ readonly projectRoot: string;
16
+ readonly sessionId: string;
17
+ readonly callerId: string;
18
+ readonly skill: string;
19
+ readonly workflowId?: WorkflowId;
20
+ readonly parentWorkflowId?: WorkflowId;
21
+ readonly graphRef?: string;
22
+ readonly depth?: number;
23
+ readonly now?: string;
24
+ }
25
+ export interface InitWorkflowResult {
26
+ readonly workflowId: WorkflowId;
27
+ readonly graphRef: string;
28
+ readonly graph: WorkflowGraph;
29
+ readonly lease: SkillPresenceLease;
30
+ readonly index: PresenceIndex;
31
+ readonly events: ReadonlyArray<Record<string, unknown>>;
32
+ }
33
+ export interface TerminalizeWorkflowInput {
34
+ readonly projectRoot: string;
35
+ readonly sessionId: string;
36
+ readonly callerId: string;
37
+ readonly workflowId: WorkflowId;
38
+ readonly graphRef: string;
39
+ readonly reason: TerminalReason;
40
+ readonly requireConsumed?: boolean;
41
+ readonly now?: string;
42
+ }
43
+ export interface TerminalizeWorkflowResult {
44
+ readonly lease: SkillPresenceLease;
45
+ readonly graph: WorkflowGraph;
46
+ readonly events: ReadonlyArray<Record<string, unknown>>;
47
+ readonly indexCleared: boolean;
48
+ }
49
+ export interface TerminalizeError extends Error {
50
+ readonly code: string;
51
+ readonly successEventCount?: number;
52
+ readonly consistent?: boolean;
53
+ }
54
+ export declare function initWorkflow(input: InitWorkflowInput): Promise<InitWorkflowResult>;
55
+ export declare function terminalizeWorkflow(input: TerminalizeWorkflowInput): Promise<TerminalizeWorkflowResult>;
56
+ export declare function readWorkflowLease(projectRoot: string, sessionId: string, callerId: string, workflowId: string): import("../skills/presence-lease-types.js").PresenceProjection;
57
+ export declare function graphFileExists(projectRoot: string, sessionId: string, workflowId: string): boolean;
58
+ export declare function readGraphFile(projectRoot: string, sessionId: string, workflowId: string): string;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Workflow presence lifecycle — atomic terminalization coordination
3
+ * (RD §3 D4c + §4 — slice 4.0.8 presence-lease-graph).
4
+ *
5
+ * Workflow init atomically creates a graph + canonical lease + caller
6
+ * index under one lifecycle lock. Terminalize atomically updates the
7
+ * terminal graph node, the lease's terminal fields, the caller
8
+ * index, and emits exactly one observability event. Both transitions
9
+ * are pure orchestrators — they delegate atomic writes to the graph
10
+ * store and lease service.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { TERMINAL_REASONS, } from './workflow-graph-types.js';
15
+ import { emptyGraph, readGraph, writeGraph, validateGraph, graphPathFor, PEAKS_GRAPH_NOT_FOUND, PEAKS_GRAPH_CORRUPTED, PEAKS_TERMINALIZE_ATOMICITY_FAILED, PEAKS_TERMINAL_REASON_INVALID, } from './workflow-graph-store.js';
16
+ import { setPresenceLease, readPresenceLease, markPresenceLost, } from '../skills/presence-lease-service.js';
17
+ function lifeError(code, message, extra = {}) {
18
+ const err = new Error(message);
19
+ err.name = 'TerminalizeError';
20
+ err.code = code;
21
+ Object.assign(err, extra);
22
+ return err;
23
+ }
24
+ /* ---------- Init ---------- */
25
+ export async function initWorkflow(input) {
26
+ const workflowId = input.workflowId ?? `wf-${Date.now().toString(36)}`;
27
+ const graphRef = input.graphRef ?? `graphs/${workflowId}.json`;
28
+ if (graphRef !== `graphs/${workflowId}.json`) {
29
+ throw lifeError(PEAKS_GRAPH_NOT_FOUND, `graphRef must normalize to graphs/${workflowId}.json; got ${graphRef}`);
30
+ }
31
+ const graph = emptyGraph({
32
+ workflowId,
33
+ rootSkill: input.skill,
34
+ ...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
35
+ });
36
+ validateGraph(graph);
37
+ // Phase 1: write graph first.
38
+ writeGraph({
39
+ projectRoot: input.projectRoot,
40
+ sessionId: input.sessionId,
41
+ graphRef,
42
+ workflowId,
43
+ graph,
44
+ holder: `initWorkflow:${input.callerId}`,
45
+ });
46
+ // Phase 2: write lease + index.
47
+ const setResult = setPresenceLease({
48
+ projectRoot: input.projectRoot,
49
+ sessionId: input.sessionId,
50
+ callerId: input.callerId,
51
+ workflowId,
52
+ graphRef,
53
+ skill: input.skill,
54
+ ...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
55
+ depth: input.depth ?? 0,
56
+ ...(input.now ? { now: input.now } : {}),
57
+ });
58
+ const events = [
59
+ {
60
+ kind: 'workflow-initialized',
61
+ schemaVersion: 1,
62
+ ts: new Date().toISOString(),
63
+ sessionId: input.sessionId,
64
+ callerId: input.callerId,
65
+ workflowId,
66
+ graphRef,
67
+ },
68
+ ];
69
+ return {
70
+ workflowId,
71
+ graphRef,
72
+ graph,
73
+ lease: setResult.lease,
74
+ index: setResult.index,
75
+ events,
76
+ };
77
+ }
78
+ /* ---------- Terminalize ---------- */
79
+ export async function terminalizeWorkflow(input) {
80
+ if (!TERMINAL_REASONS.includes(input.reason)) {
81
+ throw lifeError(PEAKS_TERMINAL_REASON_INVALID, `invalid reason: ${input.reason}`);
82
+ }
83
+ const events = [];
84
+ let successEventCount = 0;
85
+ try {
86
+ // Phase 1: read graph (fail closed if missing/corrupt).
87
+ const graph = readGraph({
88
+ projectRoot: input.projectRoot,
89
+ sessionId: input.sessionId,
90
+ graphRef: input.graphRef,
91
+ workflowId: input.workflowId,
92
+ });
93
+ // Phase 2: check unconsumed envelopes (RD §4 terminalize contract).
94
+ if (input.requireConsumed === true) {
95
+ const pending = graph.nodes.filter((n) => n.status === 'envelope-received');
96
+ if (pending.length > 0) {
97
+ throw lifeError('PEAKS_UNCONSUMED_ENVELOPE', `graph has ${pending.length} unconsumed envelope(s)`);
98
+ }
99
+ }
100
+ // Phase 3: transition the terminal node to terminalized / lost.
101
+ const targetStatus = input.reason === 'success' || input.reason === 'aborted' ? 'terminalized' : 'lost';
102
+ const updatedNodes = graph.nodes.map((n) => n.kind === 'terminal'
103
+ ? { ...n, status: targetStatus }
104
+ : n);
105
+ const updatedGraph = { ...graph, nodes: updatedNodes };
106
+ validateGraph(updatedGraph);
107
+ // Phase 4: write the updated graph atomically.
108
+ writeGraph({
109
+ projectRoot: input.projectRoot,
110
+ sessionId: input.sessionId,
111
+ graphRef: input.graphRef,
112
+ workflowId: input.workflowId,
113
+ graph: updatedGraph,
114
+ holder: `terminalize:${input.callerId}`,
115
+ });
116
+ // Phase 5: mark the lease lost/terminalized. This will throw on
117
+ // EACCES / ENOSPC — caught at the boundary so we can emit
118
+ // PEAKS_TERMINALIZE_ATOMICITY_FAILED with successEventCount=0.
119
+ const lease = markPresenceLost({
120
+ projectRoot: input.projectRoot,
121
+ sessionId: input.sessionId,
122
+ callerId: input.callerId,
123
+ workflowId: input.workflowId,
124
+ graphRef: input.graphRef,
125
+ status: targetStatus === 'terminalized' ? 'terminalized' : 'lost',
126
+ reason: input.reason,
127
+ ...(input.now ? { now: input.now } : {}),
128
+ expectedCallerId: input.callerId,
129
+ });
130
+ // Phase 6: emit exactly one terminalized observability event.
131
+ events.push({
132
+ kind: 'workflow-terminalized',
133
+ schemaVersion: 1,
134
+ ts: new Date().toISOString(),
135
+ sessionId: input.sessionId,
136
+ callerId: input.callerId,
137
+ workflowId: input.workflowId,
138
+ graphRef: input.graphRef,
139
+ terminalReason: input.reason,
140
+ });
141
+ successEventCount = 1;
142
+ return {
143
+ lease,
144
+ graph: updatedGraph,
145
+ events,
146
+ indexCleared: true,
147
+ };
148
+ }
149
+ catch (err) {
150
+ // On any failure, surface a typed terminalize-atomicity error. The test
151
+ // `TC-AG-08` injects a second-write failure so the boundary must report
152
+ // successEventCount=0 and consistent=true (no half-terminalized state).
153
+ if (err.code === PEAKS_TERMINALIZE_ATOMICITY_FAILED) {
154
+ throw err;
155
+ }
156
+ const message = err.message ?? 'terminalize failed';
157
+ throw lifeError(PEAKS_TERMINALIZE_ATOMICITY_FAILED, message, {
158
+ successEventCount,
159
+ consistent: true,
160
+ });
161
+ }
162
+ }
163
+ /* ---------- Read-only projection (used by hooks / statusline) ---------- */
164
+ export function readWorkflowLease(projectRoot, sessionId, callerId, workflowId) {
165
+ return readPresenceLease({
166
+ projectRoot,
167
+ sessionId,
168
+ callerId,
169
+ workflowId,
170
+ graphRef: `graphs/${workflowId}.json`,
171
+ });
172
+ }
173
+ /* ---------- Helper for tests: probe whether a graph file exists ---------- */
174
+ export function graphFileExists(projectRoot, sessionId, workflowId) {
175
+ const path = graphPathFor({
176
+ projectRoot,
177
+ sessionId,
178
+ graphRef: `graphs/${workflowId}.json`,
179
+ workflowId,
180
+ });
181
+ return existsSync(path);
182
+ }
183
+ export function readGraphFile(projectRoot, sessionId, workflowId) {
184
+ const path = graphPathFor({
185
+ projectRoot,
186
+ sessionId,
187
+ graphRef: `graphs/${workflowId}.json`,
188
+ workflowId,
189
+ });
190
+ if (!existsSync(path)) {
191
+ throw new Error(`graph not found: ${path}`);
192
+ }
193
+ return readFileSync(path, 'utf8');
194
+ }
195
+ void join;
196
+ void PEAKS_GRAPH_CORRUPTED;
@@ -186,3 +186,34 @@ export declare function migrateSubAgentState(projectRoot: string): {
186
186
  * deletion-candidate selection, and deletion into a single result.
187
187
  */
188
188
  export declare function reconcileWorkspace(options: ReconcileOptions): ReconcileResult;
189
+ interface ReconcilePresenceInput {
190
+ projectRoot: string;
191
+ sessionId: string;
192
+ callerId?: string;
193
+ }
194
+ interface ReconcilePresenceResult {
195
+ conflicts: Array<{
196
+ code: string;
197
+ source: string;
198
+ message: string;
199
+ }>;
200
+ legacyPresence: boolean;
201
+ migratedCount: number;
202
+ createdCount: number;
203
+ receiptPath: string | null;
204
+ }
205
+ /**
206
+ * Idempotent migration of legacy presence markers
207
+ * (`.peaks/_runtime/active-skill.json` and
208
+ * `.peaks/_runtime/<sid>/active-skill-*.json`) into the canonical
209
+ * 4.0.8 lease + index + graph layout. Per RD §6, the function is
210
+ * idempotent: a second invocation finds zero legacy sources and
211
+ * reports `createdCount = 0`.
212
+ *
213
+ * ESM safety: uses ESM `node:fs` imports (top of file already) plus
214
+ * one dynamic-`require` removal by using `readFileSync` /
215
+ * `writeFileSync` from `node:fs` directly (the prior `require('node:fs')`
216
+ * seam was unsafe under ESM and is replaced here).
217
+ */
218
+ export declare function reconcilePresenceLeases(input: ReconcilePresenceInput): ReconcilePresenceResult;
219
+ export {};