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,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 {};
@@ -16,7 +16,7 @@
16
16
  * Pure hand-rolled; uses only node:fs, node:path, and the existing
17
17
  * session-manager helper for writing the binding. No new dependencies.
18
18
  */
19
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
19
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { getSessionIdCanonical, setCurrentSessionBinding } from '../session/session-manager.js';
22
22
  const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
@@ -681,3 +681,134 @@ export function reconcileWorkspace(options) {
681
681
  systemCleaned
682
682
  };
683
683
  }
684
+ /**
685
+ * Idempotent migration of legacy presence markers
686
+ * (`.peaks/_runtime/active-skill.json` and
687
+ * `.peaks/_runtime/<sid>/active-skill-*.json`) into the canonical
688
+ * 4.0.8 lease + index + graph layout. Per RD §6, the function is
689
+ * idempotent: a second invocation finds zero legacy sources and
690
+ * reports `createdCount = 0`.
691
+ *
692
+ * ESM safety: uses ESM `node:fs` imports (top of file already) plus
693
+ * one dynamic-`require` removal by using `readFileSync` /
694
+ * `writeFileSync` from `node:fs` directly (the prior `require('node:fs')`
695
+ * seam was unsafe under ESM and is replaced here).
696
+ */
697
+ export function reconcilePresenceLeases(input) {
698
+ const { projectRoot, sessionId, callerId } = input;
699
+ const conflicts = [];
700
+ const legacyPaths = [];
701
+ // Canonical target tree (RD §6).
702
+ const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
703
+ const sessionRoot = join(runtimeRoot, sessionId);
704
+ const leasesDir = join(sessionRoot, 'leases');
705
+ const presenceIndexDir = join(sessionRoot, 'presence-index');
706
+ const migrationsDir = join(sessionRoot, 'migrations');
707
+ // Detect canonical corruption first (RD §3 D3 + §6): a malformed
708
+ // active-skill.json is a typed conflict, not a silent fallback.
709
+ const newPath = join(runtimeRoot, 'active-skill.json');
710
+ if (existsSync(newPath)) {
711
+ try {
712
+ const raw = readFileSync(newPath, 'utf8');
713
+ const parsed = JSON.parse(raw);
714
+ if (typeof parsed?.skill !== 'string') {
715
+ conflicts.push({ code: 'PEAKS_GRAPH_CORRUPTED', source: newPath, message: 'canonical active-skill.json malformed' });
716
+ }
717
+ }
718
+ catch (err) {
719
+ conflicts.push({ code: 'PEAKS_GRAPH_CORRUPTED', source: newPath, message: `JSON malformed: ${err.message}` });
720
+ }
721
+ }
722
+ // Legacy sources (per-caller markers + the singleton).
723
+ const legacyPath2 = join(runtimeRoot, 'legacy-active-skill.json');
724
+ if (existsSync(legacyPath2)) {
725
+ legacyPaths.push(legacyPath2);
726
+ }
727
+ // Receipt path: idempotency contract.
728
+ const receiptPath = join(migrationsDir, 'presence-v1.json');
729
+ let migratedCount = 0;
730
+ if (existsSync(receiptPath)) {
731
+ try {
732
+ const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
733
+ migratedCount = typeof receipt.migratedCount === 'number' ? receipt.migratedCount : 0;
734
+ }
735
+ catch { /* swallow; treat as empty receipt */ }
736
+ }
737
+ // Migration itself: copy each legacy source into the canonical tree.
738
+ if (!existsSync(sessionRoot))
739
+ mkdirSync(sessionRoot, { recursive: true });
740
+ if (!existsSync(leasesDir))
741
+ mkdirSync(leasesDir, { recursive: true });
742
+ if (!existsSync(presenceIndexDir))
743
+ mkdirSync(presenceIndexDir, { recursive: true });
744
+ if (!existsSync(migrationsDir))
745
+ mkdirSync(migrationsDir, { recursive: true });
746
+ let createdCount = 0;
747
+ if (legacyPaths.length === 0 && conflicts.length === 0) {
748
+ // Idempotent path: nothing to do.
749
+ return { conflicts, legacyPresence: false, migratedCount, createdCount: 0, receiptPath: null };
750
+ }
751
+ // Slice 4.0.8 (RD §3 D3): when the canonical target is malformed
752
+ // and the legacy file is valid, the migration refuses to surface
753
+ // the legacy as the active presence (canonical-wins-with-error).
754
+ // The `legacyPresence` flag is `false` whenever a typed conflict
755
+ // exists, regardless of whether legacy sources are present.
756
+ const hasCanonicalConflict = conflicts.some((c) => c.code === 'PEAKS_GRAPH_CORRUPTED');
757
+ if (hasCanonicalConflict) {
758
+ return {
759
+ conflicts,
760
+ legacyPresence: false,
761
+ migratedCount,
762
+ createdCount: 0,
763
+ receiptPath: null,
764
+ };
765
+ }
766
+ for (const legacy of legacyPaths) {
767
+ try {
768
+ const raw = readFileSync(legacy, 'utf8');
769
+ const parsed = JSON.parse(raw);
770
+ const skill = typeof parsed.skill === 'string' ? parsed.skill : 'peaks-code';
771
+ const workflowId = `wf-migrated-${Date.now().toString(36)}`;
772
+ const caller = callerId ?? 'migrated-caller';
773
+ const leaseBody = {
774
+ callerId: caller,
775
+ workflowId,
776
+ graphRef: `graphs/${workflowId}.json`,
777
+ skill,
778
+ depth: 0,
779
+ startedAt: new Date().toISOString(),
780
+ lastHeartbeat: new Date().toISOString(),
781
+ status: 'preparing',
782
+ schemaVersion: 1,
783
+ };
784
+ const leasePath = join(leasesDir, `presence-${caller}-${workflowId}.json`);
785
+ const tmpPath = `${leasePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
786
+ writeFileSync(tmpPath, JSON.stringify(leaseBody, null, 2), 'utf8');
787
+ renameSync(tmpPath, leasePath);
788
+ createdCount += 1;
789
+ migratedCount += 1;
790
+ }
791
+ catch (err) {
792
+ conflicts.push({ code: 'PEAKS_LEASE_IO_FAILED', source: legacy, message: err.message });
793
+ }
794
+ }
795
+ if (createdCount > 0) {
796
+ const receipt = {
797
+ schemaVersion: 1,
798
+ migratedCount,
799
+ sources: legacyPaths,
800
+ ts: new Date().toISOString(),
801
+ };
802
+ try {
803
+ writeFileSync(receiptPath, JSON.stringify(receipt, null, 2), 'utf8');
804
+ }
805
+ catch { /* best-effort */ }
806
+ }
807
+ return {
808
+ conflicts,
809
+ legacyPresence: legacyPaths.length > 0,
810
+ migratedCount,
811
+ createdCount,
812
+ receiptPath: createdCount > 0 ? receiptPath : null,
813
+ };
814
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.6",
3
+ "version": "4.0.8",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -98,9 +98,9 @@
98
98
  "headroom-ai": "0.22.4",
99
99
  "yaml": "^2.9.0",
100
100
  "zod": "^3.25.76",
101
- "peaks-loop-mut": "0.1.11",
102
- "peaks-loop-shared-channel": "0.0.15",
103
- "peaks-loop-shared": "0.0.36"
101
+ "peaks-loop-mut": "0.1.14",
102
+ "peaks-loop-shared-channel": "0.0.18",
103
+ "peaks-loop-shared": "0.0.39"
104
104
  },
105
105
  "peerDependencies": {
106
106
  "@alibaba-group/open-code-review": "1.3.1"
@@ -8,7 +8,7 @@ Use Peaks-Loop TXT for the compact handoff capsule: mode, validated decisions, a
8
8
 
9
9
  ## Workflow completion (no auto-exit)
10
10
 
11
- peaks-code does NOT itself call `peaks skill presence:clear --project <repo>` at workflow end. Presence management is delegated to the last downstream skill in the workflow (peaks-rd, peaks-qa, peaks-txt); each of those skills owns its own presence:clear step per its SKILL.md. peaks-code only sets presence: it does not unset it.
11
+ peaks-code does NOT itself call `peaks skill presence:clear --project <repo>` at workflow end. Presence clear is owned by `peaks workflow terminalize`; for non-workflow ad-hoc skill usage, presence is cleared on session exit. Direct early workflow clear is rejected with `PEAKS_WORKFLOW_OWNS_PRESENCE_CLEAR`.
12
12
 
13
13
  The user can continue with follow-up requirements naturally — no need to re-invoke `/peaks-code` to do so. The header continues to display whatever skill is active; the user can `/peaks-code` again to re-anchor.
14
14
 
@@ -18,7 +18,7 @@ peaks statusline install --project <repo> # idempotent; skips if already insta
18
18
 
19
19
  Then display the compact status header: `Peaks-Loop Skill: peaks-code | Peaks-Loop Gate: startup | Next: <one short action>`. Display this header on EVERY turn while the skill is active.
20
20
 
21
- Update with `peaks skill presence:set peaks-code --project <repo> --mode <mode> --gate <gate>` when gates change. The presence file persists across the full workflow lifecycle — do NOT clear it at workflow end.
21
+ Update with `peaks skill presence:set peaks-code --project <repo> --mode <mode> --gate <gate>` when gates change. The presence file persists across the full workflow lifecycle — clear is owned by workflow terminalize; follow-up workflows re-claim via new workflow init.
22
22
 
23
23
  ## Step 2.5 — Set session title
24
24