openclaw-node-harness 2.0.4 → 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.
- package/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +401 -12
- package/bin/mesh-bridge.js +66 -1
- package/bin/mesh-task-daemon.js +816 -26
- package/bin/mesh.js +403 -1
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +293 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +9 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +301 -1
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-budget.mjs — MEMORY.md Character Budget + Frozen Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Enforces a hard character cap on MEMORY.md (~2,200 chars, matching Hermes's
|
|
5
|
+
* proven bound) and provides frozen-snapshot semantics per session.
|
|
6
|
+
*
|
|
7
|
+
* Key concepts:
|
|
8
|
+
* - Session start: snapshot MEMORY.md and freeze it for the prompt
|
|
9
|
+
* - Mid-session writes: persist to disk but DON'T mutate the active prompt
|
|
10
|
+
* - On compression rebuild or new session: reload from disk
|
|
11
|
+
* - Usage meter: tracks % used, emits warnings at thresholds
|
|
12
|
+
*
|
|
13
|
+
* EventEmitter events:
|
|
14
|
+
* - 'add' { entry, usedChars, totalBudget, pctUsed }
|
|
15
|
+
* - 'warning' { pctUsed, message }
|
|
16
|
+
* - 'trim' { removed, reason }
|
|
17
|
+
* - 'freeze' { charCount, lineCount }
|
|
18
|
+
* - 'reload' { charCount, lineCount }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { EventEmitter } from 'events';
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CHAR_BUDGET = 2200;
|
|
26
|
+
const WARNING_THRESHOLD = 0.80; // warn at 80%
|
|
27
|
+
const CRITICAL_THRESHOLD = 0.95; // critical at 95%
|
|
28
|
+
|
|
29
|
+
export class MemoryBudget extends EventEmitter {
|
|
30
|
+
#filePath;
|
|
31
|
+
#charBudget;
|
|
32
|
+
#frozenContent = null; // snapshot at session start
|
|
33
|
+
#frozenAt = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} filePath - Absolute path to MEMORY.md
|
|
37
|
+
* @param {Object} opts
|
|
38
|
+
* @param {number} opts.charBudget - Character budget (default 2200)
|
|
39
|
+
*/
|
|
40
|
+
constructor(filePath, opts = {}) {
|
|
41
|
+
super();
|
|
42
|
+
this.#filePath = filePath;
|
|
43
|
+
this.#charBudget = opts.charBudget || DEFAULT_CHAR_BUDGET;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get filePath() { return this.#filePath; }
|
|
47
|
+
get charBudget() { return this.#charBudget; }
|
|
48
|
+
get isFrozen() { return this.#frozenContent !== null; }
|
|
49
|
+
|
|
50
|
+
// ── Snapshot Lifecycle ────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Freeze MEMORY.md content for this session.
|
|
54
|
+
* Call at session start (Phase 0 bootstrap).
|
|
55
|
+
* Returns the frozen content for prompt injection.
|
|
56
|
+
*/
|
|
57
|
+
startSession() {
|
|
58
|
+
this.#frozenContent = this.#readFile();
|
|
59
|
+
this.#frozenAt = Date.now();
|
|
60
|
+
|
|
61
|
+
const stats = this.#computeStats(this.#frozenContent);
|
|
62
|
+
this.emit('freeze', {
|
|
63
|
+
charCount: this.#frozenContent.length,
|
|
64
|
+
lineCount: this.#frozenContent.split('\n').length,
|
|
65
|
+
...stats,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return this.#frozenContent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the frozen (session-start) content for prompt rendering.
|
|
73
|
+
* Returns the frozen snapshot — NOT the live disk content.
|
|
74
|
+
* This is the core of deterministic prompt content per session.
|
|
75
|
+
*/
|
|
76
|
+
getRendered() {
|
|
77
|
+
if (this.#frozenContent === null) {
|
|
78
|
+
// Not frozen yet — return live content (pre-session or fallback)
|
|
79
|
+
return this.#readFile();
|
|
80
|
+
}
|
|
81
|
+
return this.#frozenContent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reload from disk and update the frozen snapshot.
|
|
86
|
+
* Call after compression rebuild or at new session start.
|
|
87
|
+
*/
|
|
88
|
+
reload() {
|
|
89
|
+
this.#frozenContent = this.#readFile();
|
|
90
|
+
this.#frozenAt = Date.now();
|
|
91
|
+
|
|
92
|
+
const stats = this.#computeStats(this.#frozenContent);
|
|
93
|
+
this.emit('reload', {
|
|
94
|
+
charCount: this.#frozenContent.length,
|
|
95
|
+
lineCount: this.#frozenContent.split('\n').length,
|
|
96
|
+
...stats,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return this.#frozenContent;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* End the session — clear the frozen snapshot.
|
|
104
|
+
*/
|
|
105
|
+
endSession() {
|
|
106
|
+
this.#frozenContent = null;
|
|
107
|
+
this.#frozenAt = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Budget-Aware Write ────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Add an entry to MEMORY.md on disk (not the frozen prompt).
|
|
114
|
+
* Respects character budget. Returns false if over budget.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} entry - The line to add (without leading "- ")
|
|
117
|
+
* @param {Object} opts
|
|
118
|
+
* @param {string} opts.section - Section to add under (default "Recent")
|
|
119
|
+
* @returns {{ added: boolean, pctUsed: number, charsRemaining: number }}
|
|
120
|
+
*/
|
|
121
|
+
addEntry(entry, opts = {}) {
|
|
122
|
+
const { section = 'Recent' } = opts;
|
|
123
|
+
let content = this.#readFile();
|
|
124
|
+
const line = `- ${entry}`;
|
|
125
|
+
const newLength = content.length + line.length + 1; // +1 for newline
|
|
126
|
+
|
|
127
|
+
if (newLength > this.#charBudget) {
|
|
128
|
+
// Try trimming oldest entries first
|
|
129
|
+
const trimmed = this.#trimOldest(content, line.length + 1);
|
|
130
|
+
if (trimmed) {
|
|
131
|
+
content = trimmed;
|
|
132
|
+
} else {
|
|
133
|
+
return { added: false, pctUsed: this.#pctUsed(content), charsRemaining: 0 };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Ensure section exists
|
|
138
|
+
if (!content.includes(`## ${section}`)) {
|
|
139
|
+
content = content.trimEnd() + `\n\n## ${section}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
content = content.trimEnd() + `\n${line}\n`;
|
|
143
|
+
this.#writeFile(content);
|
|
144
|
+
|
|
145
|
+
const pctUsed = this.#pctUsed(content);
|
|
146
|
+
const charsRemaining = Math.max(0, this.#charBudget - content.length);
|
|
147
|
+
|
|
148
|
+
this.emit('add', {
|
|
149
|
+
entry,
|
|
150
|
+
usedChars: content.length,
|
|
151
|
+
totalBudget: this.#charBudget,
|
|
152
|
+
pctUsed,
|
|
153
|
+
charsRemaining,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Threshold warnings
|
|
157
|
+
if (pctUsed >= CRITICAL_THRESHOLD * 100) {
|
|
158
|
+
this.emit('warning', {
|
|
159
|
+
pctUsed,
|
|
160
|
+
message: `MEMORY.md at ${pctUsed}% capacity (${charsRemaining} chars remaining)`,
|
|
161
|
+
});
|
|
162
|
+
} else if (pctUsed >= WARNING_THRESHOLD * 100) {
|
|
163
|
+
this.emit('warning', {
|
|
164
|
+
pctUsed,
|
|
165
|
+
message: `MEMORY.md approaching limit: ${pctUsed}% used (${charsRemaining} chars remaining)`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { added: true, pctUsed, charsRemaining };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Usage Meter ────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get current budget usage stats.
|
|
176
|
+
* @returns {{ usedChars: number, totalBudget: number, pctUsed: number, charsRemaining: number, lineCount: number, meterDisplay: string }}
|
|
177
|
+
*/
|
|
178
|
+
getStats() {
|
|
179
|
+
const content = this.#readFile();
|
|
180
|
+
return this.#computeStats(content);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a usage meter string for logging/display.
|
|
185
|
+
* Example: "[67% — 1,474/2,200 chars]"
|
|
186
|
+
*/
|
|
187
|
+
getMeterDisplay() {
|
|
188
|
+
return this.#computeStats(this.#readFile()).meterDisplay;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Private Helpers ────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
#readFile() {
|
|
194
|
+
if (!fs.existsSync(this.#filePath)) return '';
|
|
195
|
+
return fs.readFileSync(this.#filePath, 'utf-8');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#writeFile(content) {
|
|
199
|
+
const dir = path.dirname(this.#filePath);
|
|
200
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
fs.writeFileSync(this.#filePath, content);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#pctUsed(content) {
|
|
205
|
+
return Math.round((content.length / this.#charBudget) * 100);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#computeStats(content) {
|
|
209
|
+
const usedChars = content.length;
|
|
210
|
+
const pctUsed = this.#pctUsed(content);
|
|
211
|
+
const charsRemaining = Math.max(0, this.#charBudget - usedChars);
|
|
212
|
+
const lineCount = content.split('\n').length;
|
|
213
|
+
const meterDisplay = `[${pctUsed}% — ${usedChars.toLocaleString()}/${this.#charBudget.toLocaleString()} chars]`;
|
|
214
|
+
|
|
215
|
+
return { usedChars, totalBudget: this.#charBudget, pctUsed, charsRemaining, lineCount, meterDisplay };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Trim the oldest bullet entries to free up space.
|
|
220
|
+
* Returns the trimmed content, or null if can't free enough.
|
|
221
|
+
*/
|
|
222
|
+
#trimOldest(content, bytesNeeded) {
|
|
223
|
+
const lines = content.split('\n');
|
|
224
|
+
const bulletIndices = [];
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
if (lines[i].startsWith('- ') || lines[i].startsWith('* ')) {
|
|
228
|
+
bulletIndices.push(i);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (bulletIndices.length === 0) return null;
|
|
233
|
+
|
|
234
|
+
// Remove bullets from the top until we have enough space
|
|
235
|
+
let freed = 0;
|
|
236
|
+
const toRemove = new Set();
|
|
237
|
+
|
|
238
|
+
for (const idx of bulletIndices) {
|
|
239
|
+
if (freed >= bytesNeeded) break;
|
|
240
|
+
freed += lines[idx].length + 1;
|
|
241
|
+
toRemove.add(idx);
|
|
242
|
+
|
|
243
|
+
this.emit('trim', { removed: lines[idx], reason: 'budget overflow' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (freed < bytesNeeded) return null;
|
|
247
|
+
|
|
248
|
+
const trimmed = lines.filter((_, i) => !toRemove.has(i)).join('\n');
|
|
249
|
+
return trimmed;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create a MemoryBudget instance with default OpenClaw paths.
|
|
255
|
+
* @param {string} workspace - OpenClaw workspace root
|
|
256
|
+
* @param {Object} opts - Options forwarded to MemoryBudget constructor
|
|
257
|
+
*/
|
|
258
|
+
export function createBudget(workspace, opts = {}) {
|
|
259
|
+
const filePath = path.join(workspace, 'MEMORY.md');
|
|
260
|
+
return new MemoryBudget(filePath, opts);
|
|
261
|
+
}
|
package/lib/mesh-collab.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -374,6 +399,9 @@ class CollabStore {
|
|
|
374
399
|
vote: reflection.vote || 'continue',
|
|
375
400
|
parse_failed: reflection.parse_failed || false,
|
|
376
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 }]
|
|
377
405
|
});
|
|
378
406
|
|
|
379
407
|
// Update node status
|
|
@@ -476,6 +504,278 @@ class CollabStore {
|
|
|
476
504
|
return session.current_round >= session.max_rounds;
|
|
477
505
|
}
|
|
478
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
|
+
|
|
479
779
|
// ── Intel Compilation ──────────────────────────────
|
|
480
780
|
|
|
481
781
|
/**
|