openclaw-node-harness 2.0.3 → 2.1.0

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 (118) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +603 -81
  4. package/bin/mesh-bridge.js +340 -11
  5. package/bin/mesh-deploy-listener.js +119 -97
  6. package/bin/mesh-deploy.js +8 -0
  7. package/bin/mesh-task-daemon.js +1005 -40
  8. package/bin/mesh.js +423 -6
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +300 -8
  29. package/lib/circling-parser.js +119 -0
  30. package/lib/hyperagent-store.mjs +652 -0
  31. package/lib/kanban-io.js +59 -10
  32. package/lib/mcp-knowledge/bench.mjs +118 -0
  33. package/lib/mcp-knowledge/core.mjs +528 -0
  34. package/lib/mcp-knowledge/package.json +25 -0
  35. package/lib/mcp-knowledge/server.mjs +245 -0
  36. package/lib/mcp-knowledge/test.mjs +802 -0
  37. package/lib/memory-budget.mjs +261 -0
  38. package/lib/mesh-collab.js +354 -4
  39. package/lib/mesh-harness.js +427 -0
  40. package/lib/mesh-plans.js +13 -5
  41. package/lib/mesh-registry.js +11 -2
  42. package/lib/mesh-tasks.js +67 -0
  43. package/lib/plan-templates.js +226 -0
  44. package/lib/pre-compression-flush.mjs +320 -0
  45. package/lib/role-loader.js +292 -0
  46. package/lib/rule-loader.js +358 -0
  47. package/lib/session-store.mjs +458 -0
  48. package/lib/transcript-parser.mjs +292 -0
  49. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  50. package/mission-control/drizzle.config.ts +1 -4
  51. package/mission-control/package-lock.json +1571 -83
  52. package/mission-control/package.json +6 -2
  53. package/mission-control/scripts/gen-chronology.js +3 -3
  54. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  55. package/mission-control/scripts/import-pipeline.js +0 -15
  56. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  57. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  58. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  59. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  60. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  61. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  62. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  63. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  64. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  65. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  66. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  67. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  68. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  69. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  70. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  71. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  72. package/mission-control/src/app/api/tasks/route.ts +21 -30
  73. package/mission-control/src/app/cowork/page.tsx +261 -0
  74. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  75. package/mission-control/src/app/graph/page.tsx +26 -0
  76. package/mission-control/src/app/memory/page.tsx +1 -1
  77. package/mission-control/src/app/obsidian/page.tsx +36 -6
  78. package/mission-control/src/app/roadmap/page.tsx +24 -0
  79. package/mission-control/src/app/souls/page.tsx +2 -2
  80. package/mission-control/src/components/board/execution-config.tsx +431 -0
  81. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  82. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  83. package/mission-control/src/components/board/task-card.tsx +55 -2
  84. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  85. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  86. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  87. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  88. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  89. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  90. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  91. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  92. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  93. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  94. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  95. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  96. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  97. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  98. package/mission-control/src/lib/config.ts +58 -0
  99. package/mission-control/src/lib/db/index.ts +69 -0
  100. package/mission-control/src/lib/db/schema.ts +61 -3
  101. package/mission-control/src/lib/hooks.ts +309 -0
  102. package/mission-control/src/lib/memory/entities.ts +3 -2
  103. package/mission-control/src/lib/nats.ts +66 -1
  104. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  105. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  106. package/mission-control/src/lib/scheduler.ts +12 -11
  107. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  108. package/mission-control/src/lib/sync/tasks.ts +23 -1
  109. package/mission-control/src/lib/task-id.ts +32 -0
  110. package/mission-control/src/lib/tts/index.ts +33 -9
  111. package/mission-control/tsconfig.json +2 -1
  112. package/mission-control/vitest.config.ts +14 -0
  113. package/package.json +15 -2
  114. package/services/service-manifest.json +1 -1
  115. package/skills/cc-godmode/references/agents.md +8 -8
  116. package/workspace-bin/memory-daemon.mjs +199 -5
  117. package/workspace-bin/session-search.mjs +204 -0
  118. package/workspace-bin/web-fetch.mjs +65 -0
@@ -29,6 +29,7 @@ const COLLAB_MODE = {
29
29
  PARALLEL: 'parallel', // all nodes work simultaneously
30
30
  SEQUENTIAL: 'sequential', // nodes take turns in order
31
31
  REVIEW: 'review', // one leader + N reviewers
32
+ CIRCLING_STRATEGY: 'circling_strategy', // 1 worker + 2 reviewers, asymmetric directed rounds
32
33
  };
33
34
 
34
35
  // ── Convergence Strategies ──────────────────────────
@@ -57,7 +58,9 @@ function createSession(taskId, collabSpec) {
57
58
  status: COLLAB_STATUS.RECRUITING,
58
59
 
59
60
  // Node management
60
- min_nodes: collabSpec.min_nodes || 2,
61
+ // Circling requires at least 3 nodes (1 worker + 2 reviewers).
62
+ // Default to 3 for circling, 2 for other modes.
63
+ min_nodes: collabSpec.min_nodes || (collabSpec.mode === COLLAB_MODE.CIRCLING_STRATEGY ? 3 : 2),
61
64
  max_nodes: collabSpec.max_nodes || null, // null = unlimited
62
65
  join_window_s: collabSpec.join_window_s || 30,
63
66
  nodes: [],
@@ -85,10 +88,32 @@ function createSession(taskId, collabSpec) {
85
88
  // Scope strategy
86
89
  scope_strategy: collabSpec.scope_strategy || 'shared',
87
90
 
91
+ // Heterogeneous collab: per-node role/soul assignments (Phase E)
92
+ // Format: [{ soul: "blockchain-auditor", role: "solidity-dev" }, { soul: "identity-architect" }]
93
+ // When set, recruiting assigns specific souls to joining nodes in order.
94
+ // When null, all nodes run the same soul (homogeneous, backward compatible).
95
+ node_roles: collabSpec.node_roles || null,
96
+
88
97
  // Sequential mode: turn tracking
89
98
  turn_order: [], // node_ids in execution order
90
99
  current_turn: null, // node_id of active node (sequential mode)
91
100
 
101
+ // Circling Strategy mode: asymmetric directed rounds
102
+ // Only populated when mode === 'circling_strategy'. null for other modes.
103
+ circling: collabSpec.mode === COLLAB_MODE.CIRCLING_STRATEGY ? {
104
+ worker_node_id: null, // assigned at recruiting close (node_roles[0])
105
+ reviewerA_node_id: null, // assigned at recruiting close — first non-worker
106
+ reviewerB_node_id: null, // assigned at recruiting close — second non-worker
107
+ max_subrounds: collabSpec.max_subrounds || 3,
108
+ current_subround: 0,
109
+ current_step: 0, // 0 = init, 1 = review pass, 2 = integration
110
+ automation_tier: collabSpec.automation_tier || 2,
111
+ artifacts: {}, // keyed: sr{N}_step{S}_{nodeRole}_{artifactType}
112
+ phase: 'init', // init | circling | finalization | complete
113
+ artifact_failures: {}, // { nodeId_step: count } — retry tracking per node per step
114
+ step_started_at: null, // ISO timestamp — set by daemon at step start, used for timeout rehydration after restart
115
+ } : null,
116
+
92
117
  // Result (filled at completion)
93
118
  result: null,
94
119
 
@@ -105,6 +130,10 @@ function createSession(taskId, collabSpec) {
105
130
 
106
131
  // ── CollabStore (KV-backed) ─────────────────────────
107
132
 
133
+ // Rate-limit audit error logs: max 3 per session, then go silent
134
+ const _auditErrorCounts = new Map();
135
+ const AUDIT_ERROR_LOG_LIMIT = 3;
136
+
108
137
  class CollabStore {
109
138
  constructor(kv) {
110
139
  this.kv = kv;
@@ -139,7 +168,14 @@ class CollabStore {
139
168
  ...detail,
140
169
  });
141
170
  await this.put(session);
142
- } catch { /* best-effort — never block on audit */ }
171
+ } catch (err) {
172
+ // Best-effort — never block on audit, but log first N failures per session
173
+ const count = (_auditErrorCounts.get(sessionId) || 0) + 1;
174
+ _auditErrorCounts.set(sessionId, count);
175
+ if (count <= AUDIT_ERROR_LOG_LIMIT) {
176
+ console.error(`[collab] audit append failed for ${sessionId}/${event}: ${err.message}${count === AUDIT_ERROR_LOG_LIMIT ? ' (suppressing further audit errors for this session)' : ''}`);
177
+ }
178
+ }
143
179
  }
144
180
 
145
181
  /**
@@ -176,6 +212,30 @@ class CollabStore {
176
212
  return sessions[0] || null;
177
213
  }
178
214
 
215
+ /**
216
+ * Find active sessions that contain a given node.
217
+ * O(sessions) single pass — avoids the O(sessions × nodes) scan
218
+ * that detectStalls() previously used with list() + inner find().
219
+ */
220
+ async findActiveSessionsByNode(nodeId) {
221
+ const results = [];
222
+ const allKeys = [];
223
+ const keys = await this.kv.keys();
224
+ for await (const key of keys) {
225
+ allKeys.push(key);
226
+ }
227
+ for (const key of allKeys) {
228
+ const entry = await this.kv.get(key);
229
+ if (!entry || !entry.value) continue;
230
+ const session = JSON.parse(sc.decode(entry.value));
231
+ if (session.status !== COLLAB_STATUS.ACTIVE) continue;
232
+ if (session.nodes.some(n => n.node_id === nodeId)) {
233
+ results.push(session);
234
+ }
235
+ }
236
+ return results;
237
+ }
238
+
179
239
  // ── Node Management ────────────────────────────────
180
240
 
181
241
  /**
@@ -190,7 +250,8 @@ class CollabStore {
190
250
  // Check max_nodes
191
251
  if (session.max_nodes && session.nodes.length >= session.max_nodes) return null;
192
252
 
193
- // Check duplicate
253
+ // Check duplicate — single-threaded event loop prevents concurrent joins
254
+ // from interleaving between find() and push(). No mutex needed.
194
255
  if (session.nodes.find(n => n.node_id === nodeId)) return null;
195
256
 
196
257
  session.nodes.push({
@@ -320,6 +381,9 @@ class CollabStore {
320
381
  const session = await this.get(sessionId);
321
382
  if (!session) return null;
322
383
 
384
+ // Only accept reflections on active sessions
385
+ if (session.status !== COLLAB_STATUS.ACTIVE) return null;
386
+
323
387
  const currentRound = session.rounds[session.rounds.length - 1];
324
388
  if (!currentRound) return null;
325
389
 
@@ -335,6 +399,9 @@ class CollabStore {
335
399
  vote: reflection.vote || 'continue',
336
400
  parse_failed: reflection.parse_failed || false,
337
401
  submitted_at: new Date().toISOString(),
402
+ // Circling Strategy extensions (optional, backward compatible)
403
+ circling_step: reflection.circling_step ?? null,
404
+ circling_artifacts: reflection.circling_artifacts || [], // [{ type, content }]
338
405
  });
339
406
 
340
407
  // Update node status
@@ -437,6 +504,278 @@ class CollabStore {
437
504
  return session.current_round >= session.max_rounds;
438
505
  }
439
506
 
507
+ // ── Circling Strategy: Artifact Store ──────────────
508
+
509
+ /**
510
+ * Store a typed artifact from a circling step.
511
+ * Key format: sr{N}_step{S}_{nodeRole}_{artifactType}
512
+ */
513
+ async storeArtifact(sessionId, key, content) {
514
+ const session = await this.get(sessionId);
515
+ if (!session || !session.circling) return null;
516
+ session.circling.artifacts[key] = content;
517
+
518
+ // Session blob size check — JetStream KV max is 1MB.
519
+ // Warn early so operators can plan external artifact store before hitting the wall.
520
+ const blobSize = Buffer.byteLength(JSON.stringify(session), 'utf8');
521
+ if (blobSize > 950_000) {
522
+ console.error(`[collab] CRITICAL: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — approaching JetStream KV 1MB limit`);
523
+ } else if (blobSize > 800_000) {
524
+ console.warn(`[collab] WARNING: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — consider external artifact store`);
525
+ }
526
+
527
+ try {
528
+ await this.put(session);
529
+ } catch (err) {
530
+ // JetStream KV write failure — likely blob exceeded 1MB limit.
531
+ // Remove the artifact that caused the overflow and re-persist without it.
532
+ console.error(`[collab] storeArtifact FAILED for ${sessionId}/${key}: ${err.message}. Removing artifact and persisting without it.`);
533
+ delete session.circling.artifacts[key];
534
+ await this.put(session);
535
+ return null;
536
+ }
537
+
538
+ return session;
539
+ }
540
+
541
+ /**
542
+ * Retrieve an artifact by key.
543
+ */
544
+ getArtifactByKey(session, key) {
545
+ if (!session || !session.circling) return null;
546
+ return session.circling.artifacts[key] || null;
547
+ }
548
+
549
+ /**
550
+ * Find the most recent version of an artifact type from a given role.
551
+ * Scans backward from (current_subround - 1) step 2 → sr0_step0 (init).
552
+ * Returns null if not found.
553
+ */
554
+ getLatestArtifact(session, nodeRole, artifactType) {
555
+ if (!session || !session.circling) return null;
556
+ const { current_subround, artifacts } = session.circling;
557
+
558
+ // Scan backward through sub-rounds
559
+ for (let sr = current_subround; sr >= 0; sr--) {
560
+ // Step 2 artifacts (produced during integration/refinement)
561
+ if (sr > 0) {
562
+ const key2 = `sr${sr}_step2_${nodeRole}_${artifactType}`;
563
+ if (artifacts[key2] !== undefined) return artifacts[key2];
564
+ }
565
+ // Step 1 artifacts (produced during review pass)
566
+ if (sr > 0) {
567
+ const key1 = `sr${sr}_step1_${nodeRole}_${artifactType}`;
568
+ if (artifacts[key1] !== undefined) return artifacts[key1];
569
+ }
570
+ // Init artifacts (sr0_step0)
571
+ if (sr === 0) {
572
+ const key0 = `sr0_step0_${nodeRole}_${artifactType}`;
573
+ if (artifacts[key0] !== undefined) return artifacts[key0];
574
+ }
575
+ }
576
+ return null;
577
+ }
578
+
579
+ /**
580
+ * Build node-specific directed input for a circling step.
581
+ * Uses the information flow matrix from the Circling Strategy protocol spec (§5).
582
+ *
583
+ * Returns a string containing the directed artifacts for this node at this step.
584
+ */
585
+ compileDirectedInput(session, nodeId, taskDescription) {
586
+ if (!session || !session.circling) return '';
587
+ const { phase, current_subround, current_step } = session.circling;
588
+ const node = session.nodes.find(n => n.node_id === nodeId);
589
+ if (!node) return '';
590
+
591
+ const isWorker = nodeId === session.circling.worker_node_id;
592
+ const parts = [];
593
+
594
+ // Use stored reviewer IDs (assigned at recruiting close) for stable identity.
595
+ // Falls back to array-index computation if IDs aren't set (backward compat).
596
+ const reviewerLabel = (nId) => {
597
+ if (session.circling.reviewerA_node_id && session.circling.reviewerB_node_id) {
598
+ return nId === session.circling.reviewerA_node_id ? 'reviewerA' : 'reviewerB';
599
+ }
600
+ // Legacy fallback: compute from array position
601
+ const reviewerNodes = session.nodes.filter(n => n.node_id !== session.circling.worker_node_id);
602
+ const idx = reviewerNodes.findIndex(n => n.node_id === nId);
603
+ return idx === 0 ? 'reviewerA' : 'reviewerB';
604
+ };
605
+ const myReviewerRole = !isWorker ? reviewerLabel(nodeId) : null;
606
+
607
+ // Helper: add artifact to parts, handling null (required vs optional)
608
+ const addArtifact = (label, nodeRole, artifactType, required) => {
609
+ const content = this.getLatestArtifact(session, nodeRole, artifactType);
610
+ if (content !== null) {
611
+ parts.push(`## ${label}\n\n${content}`);
612
+ } else if (required) {
613
+ parts.push(`## ${label}\n\n[UNAVAILABLE: ${nodeRole}'s ${artifactType} was not produced — proceed with available inputs only]`);
614
+ }
615
+ // If not required and null, skip silently
616
+ };
617
+
618
+ switch (phase) {
619
+ case 'init':
620
+ parts.push(`## Task Plan\n\n${taskDescription || '(no description)'}`);
621
+ break;
622
+
623
+ case 'circling':
624
+ if (current_step === 1) {
625
+ // Step 1 — Review Pass
626
+ if (isWorker) {
627
+ // Worker receives: both reviewStrategies
628
+ addArtifact('Reviewer A Strategy', 'reviewerA', 'reviewStrategy', true);
629
+ addArtifact('Reviewer B Strategy', 'reviewerB', 'reviewStrategy', true);
630
+ // SR2+: also include reviewArtifacts so Worker can assess whether
631
+ // strategies are producing useful reviews (evidence alongside methodology)
632
+ if (current_subround > 1) {
633
+ addArtifact('Reviewer A — Review Findings', 'reviewerA', 'reviewArtifact', false);
634
+ addArtifact('Reviewer B — Review Findings', 'reviewerB', 'reviewArtifact', false);
635
+ }
636
+ } else {
637
+ // Reviewer receives: workArtifact + reconciliationDoc (optional in SR1)
638
+ addArtifact('Work Artifact', 'worker', 'workArtifact', true);
639
+ addArtifact('Reconciliation Document', 'worker', 'reconciliationDoc', current_subround > 1);
640
+ }
641
+ } else if (current_step === 2) {
642
+ // Step 2 — Integration + Refinement
643
+ if (isWorker) {
644
+ // Worker receives: both reviewArtifacts
645
+ addArtifact('Reviewer A Review', 'reviewerA', 'reviewArtifact', true);
646
+ addArtifact('Reviewer B Review', 'reviewerB', 'reviewArtifact', true);
647
+ } else {
648
+ // Reviewer receives: workerReviewsAnalysis + cross-review from the other reviewer.
649
+ // Cross-review enables inter-reviewer learning: "B caught something my
650
+ // methodology missed — I should incorporate that lens."
651
+ addArtifact('Worker Reviews Analysis', 'worker', 'workerReviewsAnalysis', true);
652
+ const otherReviewerRole = (myReviewerRole === 'reviewerA') ? 'reviewerB' : 'reviewerA';
653
+ addArtifact(`Cross-Review — ${otherReviewerRole} Findings`, otherReviewerRole, 'reviewArtifact', false);
654
+ }
655
+ }
656
+ break;
657
+
658
+ case 'finalization':
659
+ parts.push(`## Original Task Plan\n\n${taskDescription || '(no description)'}`);
660
+ addArtifact('Final Work Artifact', 'worker', 'workArtifact', true);
661
+ break;
662
+ }
663
+
664
+ return parts.join('\n\n---\n\n');
665
+ }
666
+
667
+ /**
668
+ * Check if all nodes have submitted for the current circling step.
669
+ */
670
+ isCirclingStepComplete(session) {
671
+ if (!session || !session.circling) return false;
672
+ const currentRound = session.rounds[session.rounds.length - 1];
673
+ if (!currentRound) return false;
674
+
675
+ const activeNodes = session.nodes.filter(n => n.status !== 'dead');
676
+ // For circling, reflections per step are tagged. Count reflections matching current step.
677
+ const stepReflections = currentRound.reflections.filter(
678
+ r => r.circling_step === session.circling.current_step
679
+ );
680
+ return stepReflections.length >= activeNodes.length;
681
+ }
682
+
683
+ /**
684
+ * Advance the circling state machine.
685
+ * Returns { phase, subround, step, needsGate } describing the new state.
686
+ *
687
+ * State machine transitions:
688
+ * init/step0 → circling/SR1/step1
689
+ * circling/step1 → circling/step2 (same SR)
690
+ * circling/step2 (SR < max) → circling/SR+1/step1
691
+ * circling/step2 (SR == max) → finalization/step0
692
+ */
693
+ async advanceCirclingStep(sessionId) {
694
+ const session = await this.get(sessionId);
695
+ if (!session || !session.circling) return null;
696
+
697
+ const c = session.circling;
698
+ let needsGate = false;
699
+
700
+ if (c.phase === 'init' && c.current_step === 0) {
701
+ // Init complete → start circling SR1/Step1
702
+ c.phase = 'circling';
703
+ c.current_subround = 1;
704
+ c.current_step = 1;
705
+ } else if (c.phase === 'circling' && c.current_step === 1) {
706
+ // Step 1 complete → Step 2 (same subround)
707
+ c.current_step = 2;
708
+ } else if (c.phase === 'circling' && c.current_step === 2) {
709
+ // Adaptive convergence: if all active nodes voted 'converged' after step 2,
710
+ // skip remaining sub-rounds and go directly to finalization.
711
+ const currentRound = session.rounds[session.rounds.length - 1];
712
+ const activeNodes = session.nodes.filter(n => n.status !== 'dead');
713
+ const step2Reflections = currentRound
714
+ ? currentRound.reflections.filter(r => r.circling_step === 2)
715
+ : [];
716
+ const allConverged = step2Reflections.length >= activeNodes.length &&
717
+ step2Reflections.every(r => r.vote === 'converged');
718
+
719
+ if (allConverged && c.current_subround < c.max_subrounds) {
720
+ // Early exit — all nodes agree the work is ready
721
+ if (c.automation_tier >= 2) {
722
+ needsGate = true;
723
+ }
724
+ c.phase = 'finalization';
725
+ c.current_step = 0;
726
+ } else if (c.current_subround < c.max_subrounds) {
727
+ // Step 2 complete, more sub-rounds → next SR/Step1
728
+ // Check tier gate for Tier 3 (gates after every sub-round)
729
+ if (c.automation_tier === 3) {
730
+ needsGate = true;
731
+ }
732
+ c.current_subround++;
733
+ c.current_step = 1;
734
+ } else {
735
+ // Final sub-round complete → finalization
736
+ // Tier 2 gates on finalization entry
737
+ if (c.automation_tier >= 2) {
738
+ needsGate = true;
739
+ }
740
+ c.phase = 'finalization';
741
+ c.current_step = 0;
742
+ }
743
+ } else if (c.phase === 'finalization') {
744
+ // Finalization complete → done
745
+ c.phase = 'complete';
746
+ }
747
+
748
+ await this.put(session);
749
+ return {
750
+ phase: c.phase,
751
+ subround: c.current_subround,
752
+ step: c.current_step,
753
+ needsGate,
754
+ };
755
+ }
756
+
757
+ /**
758
+ * Record an artifact parse failure for a node at the current step.
759
+ * Returns the failure count.
760
+ */
761
+ async recordArtifactFailure(sessionId, nodeId) {
762
+ const session = await this.get(sessionId);
763
+ if (!session || !session.circling) return 0;
764
+ const key = `${nodeId}_sr${session.circling.current_subround}_step${session.circling.current_step}`;
765
+ session.circling.artifact_failures[key] = (session.circling.artifact_failures[key] || 0) + 1;
766
+ await this.put(session);
767
+ return session.circling.artifact_failures[key];
768
+ }
769
+
770
+ /**
771
+ * Get the artifact failure count for a node at the current step.
772
+ */
773
+ getArtifactFailureCount(session, nodeId) {
774
+ if (!session || !session.circling) return 0;
775
+ const key = `${nodeId}_sr${session.circling.current_subround}_step${session.circling.current_step}`;
776
+ return session.circling.artifact_failures[key] || 0;
777
+ }
778
+
440
779
  // ── Intel Compilation ──────────────────────────────
441
780
 
442
781
  /**
@@ -506,11 +845,14 @@ class CollabStore {
506
845
  }
507
846
 
508
847
  /**
509
- * Mark session as aborted.
848
+ * Mark session as aborted. Returns null (no-op) if already completed or aborted.
849
+ * Callers can use truthiness to detect whether the abort actually happened.
510
850
  */
511
851
  async markAborted(sessionId, reason) {
512
852
  const session = await this.get(sessionId);
513
853
  if (!session) return null;
854
+ // Guard: don't corrupt completed/aborted sessions
855
+ if (['completed', 'aborted'].includes(session.status)) return null;
514
856
  session.status = COLLAB_STATUS.ABORTED;
515
857
  session.completed_at = new Date().toISOString();
516
858
  session.result = { success: false, summary: reason, aborted: true };
@@ -518,6 +860,14 @@ class CollabStore {
518
860
  return session;
519
861
  }
520
862
 
863
+ /**
864
+ * Clear the audit error rate-limit counter for a session.
865
+ * Call when a session is finalized (completed/aborted) to prevent Map leak.
866
+ */
867
+ clearAuditErrorCount(sessionId) {
868
+ _auditErrorCounts.delete(sessionId);
869
+ }
870
+
521
871
  /**
522
872
  * Get a summary of the session for reporting.
523
873
  */