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.
- package/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +603 -81
- package/bin/mesh-bridge.js +340 -11
- package/bin/mesh-deploy-listener.js +119 -97
- package/bin/mesh-deploy.js +8 -0
- package/bin/mesh-task-daemon.js +1005 -40
- package/bin/mesh.js +423 -6
- 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 +300 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +59 -10
- 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 +354 -4
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-registry.js +11 -2
- 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
|
@@ -30,7 +30,7 @@ export default function MemoryPage() {
|
|
|
30
30
|
const isSearching = debouncedQuery.length >= 2;
|
|
31
31
|
|
|
32
32
|
// Debounce search input
|
|
33
|
-
const debounceRef = useRef<NodeJS.Timeout>();
|
|
33
|
+
const debounceRef = useRef<NodeJS.Timeout>(undefined);
|
|
34
34
|
const handleInputChange = useCallback(
|
|
35
35
|
(value: string) => {
|
|
36
36
|
setQuery(value);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
PanelLeftClose,
|
|
18
18
|
PanelLeftOpen,
|
|
19
19
|
Filter,
|
|
20
|
+
RefreshCw,
|
|
20
21
|
} from "lucide-react";
|
|
21
22
|
|
|
22
23
|
const SOURCE_FILTERS = [
|
|
@@ -35,6 +36,18 @@ export default function ObsidianPage() {
|
|
|
35
36
|
const [showFileTree, setShowFileTree] = useState(true);
|
|
36
37
|
const [sourceFilter, setSourceFilter] = useState<string>("all");
|
|
37
38
|
const [showOrphans, setShowOrphans] = useState(true);
|
|
39
|
+
const [indexing, setIndexing] = useState(false);
|
|
40
|
+
|
|
41
|
+
const handleIndexWorkspace = async () => {
|
|
42
|
+
setIndexing(true);
|
|
43
|
+
try {
|
|
44
|
+
await fetch("/api/memory/flush", { method: "POST" });
|
|
45
|
+
// Force SWR to revalidate the graph
|
|
46
|
+
window.location.reload();
|
|
47
|
+
} catch {
|
|
48
|
+
setIndexing(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
38
51
|
|
|
39
52
|
// Try indexed doc first (has metadata), fall back to raw workspace file
|
|
40
53
|
const { doc: indexedDoc, isLoading: idxLoading } = useMemoryDoc(selectedPath);
|
|
@@ -213,12 +226,29 @@ export default function ObsidianPage() {
|
|
|
213
226
|
|
|
214
227
|
{/* Center: Graph */}
|
|
215
228
|
<div className="flex-1 overflow-hidden">
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
{!graphLoading && filteredGraph && filteredGraph.nodes.length === 0 ? (
|
|
230
|
+
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-3">
|
|
231
|
+
<p className="text-sm">No documents indexed yet</p>
|
|
232
|
+
<p className="text-xs text-muted-foreground/60 max-w-xs text-center">
|
|
233
|
+
Index your workspace to build the wikilink graph from your markdown files.
|
|
234
|
+
</p>
|
|
235
|
+
<button
|
|
236
|
+
onClick={handleIndexWorkspace}
|
|
237
|
+
disabled={indexing}
|
|
238
|
+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
239
|
+
>
|
|
240
|
+
<RefreshCw className={`h-4 w-4 ${indexing ? "animate-spin" : ""}`} />
|
|
241
|
+
{indexing ? "Indexing..." : "Index Workspace"}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
) : (
|
|
245
|
+
<ObsidianGraph
|
|
246
|
+
graph={filteredGraph}
|
|
247
|
+
isLoading={graphLoading}
|
|
248
|
+
selectedNode={selectedPath}
|
|
249
|
+
onNodeClick={handleNodeClick}
|
|
250
|
+
/>
|
|
251
|
+
)}
|
|
222
252
|
</div>
|
|
223
253
|
|
|
224
254
|
{/* Right: Reader panel */}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
ArrowLeftFromLine,
|
|
39
39
|
ArrowRightFromLine,
|
|
40
40
|
Clock,
|
|
41
|
+
Send,
|
|
41
42
|
} from "lucide-react";
|
|
42
43
|
import {
|
|
43
44
|
useProjects,
|
|
@@ -1298,6 +1299,29 @@ export default function RoadmapPage() {
|
|
|
1298
1299
|
>
|
|
1299
1300
|
<GitBranch className="h-3 w-3" />
|
|
1300
1301
|
</button>
|
|
1302
|
+
|
|
1303
|
+
{/* Send to Kanban — only for leaf task nodes */}
|
|
1304
|
+
{node.type === "task" && (
|
|
1305
|
+
node.status === "queued" || node.status === "not started" ? (
|
|
1306
|
+
<button
|
|
1307
|
+
onClick={async (e) => {
|
|
1308
|
+
e.stopPropagation();
|
|
1309
|
+
await updateTask(node.id, {
|
|
1310
|
+
kanbanColumn: "backlog",
|
|
1311
|
+
status: "queued",
|
|
1312
|
+
} as Record<string, unknown>);
|
|
1313
|
+
}}
|
|
1314
|
+
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-primary p-0.5 mr-1 transition-colors"
|
|
1315
|
+
title="Send to Kanban backlog"
|
|
1316
|
+
>
|
|
1317
|
+
<Send className="h-3 w-3" />
|
|
1318
|
+
</button>
|
|
1319
|
+
) : (
|
|
1320
|
+
<span className="text-[9px] text-green-400 shrink-0 px-1">
|
|
1321
|
+
In Kanban
|
|
1322
|
+
</span>
|
|
1323
|
+
)
|
|
1324
|
+
)}
|
|
1301
1325
|
</div>
|
|
1302
1326
|
);
|
|
1303
1327
|
})}
|
|
@@ -269,7 +269,7 @@ touch ~/.openclaw/souls/my-soul/evolution/events.jsonl`}
|
|
|
269
269
|
},
|
|
270
270
|
"specializations": ["domain1", "domain2"],
|
|
271
271
|
"evolutionEnabled": true,
|
|
272
|
-
"parentSoul": "
|
|
272
|
+
"parentSoul": "main-agent"
|
|
273
273
|
}'`}
|
|
274
274
|
</pre>
|
|
275
275
|
<p className="text-xs text-muted-foreground mt-2">
|
|
@@ -319,7 +319,7 @@ export default function SoulsPage() {
|
|
|
319
319
|
{
|
|
320
320
|
method: "PATCH",
|
|
321
321
|
headers: { "Content-Type": "application/json" },
|
|
322
|
-
body: JSON.stringify({ action, reviewedBy: "
|
|
322
|
+
body: JSON.stringify({ action, reviewedBy: "main-agent" }),
|
|
323
323
|
}
|
|
324
324
|
);
|
|
325
325
|
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
import { useClusters } from "@/lib/hooks";
|
|
6
|
+
|
|
7
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
8
|
+
|
|
9
|
+
type ExecutionMode = "local" | "mesh" | "collab";
|
|
10
|
+
|
|
11
|
+
export interface ExecutionFields {
|
|
12
|
+
execution: string | null;
|
|
13
|
+
collaboration: Record<string, unknown> | null;
|
|
14
|
+
preferred_nodes: string[];
|
|
15
|
+
exclude_nodes: string[];
|
|
16
|
+
cluster_id: string | null;
|
|
17
|
+
metric: string | null;
|
|
18
|
+
budget_minutes: number;
|
|
19
|
+
scope: string[];
|
|
20
|
+
needs_approval: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExecutionConfigProps {
|
|
24
|
+
value: ExecutionFields;
|
|
25
|
+
onChange: (fields: ExecutionFields) => void;
|
|
26
|
+
disabled?: boolean; // true when task has meshTaskId (already submitted)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MODES: { value: ExecutionMode; label: string }[] = [
|
|
30
|
+
{ value: "local", label: "Local" },
|
|
31
|
+
{ value: "mesh", label: "Mesh" },
|
|
32
|
+
{ value: "collab", label: "Collab" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const COLLAB_MODES = ["parallel", "sequential", "review"] as const;
|
|
36
|
+
const CONVERGENCE_TYPES = ["unanimous", "majority", "coordinator"] as const;
|
|
37
|
+
const SCOPE_STRATEGIES = ["shared", "leader_only", "partitioned"] as const;
|
|
38
|
+
|
|
39
|
+
export function ExecutionConfig({ value, onChange, disabled }: ExecutionConfigProps) {
|
|
40
|
+
const mode: ExecutionMode =
|
|
41
|
+
value.execution === "mesh"
|
|
42
|
+
? value.collaboration
|
|
43
|
+
? "collab"
|
|
44
|
+
: "mesh"
|
|
45
|
+
: "local";
|
|
46
|
+
|
|
47
|
+
// Collab settings (parsed from collaboration JSON)
|
|
48
|
+
const collab = (value.collaboration ?? {}) as Record<string, unknown>;
|
|
49
|
+
const conv = (collab.convergence ?? {}) as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
const [collabMode, setCollabMode] = useState<string>((collab.mode as string) || "parallel");
|
|
52
|
+
const [convergenceType, setConvergenceType] = useState<string>((conv.type as string) || "unanimous");
|
|
53
|
+
const [convergenceThreshold, setConvergenceThreshold] = useState<number>(
|
|
54
|
+
typeof conv.threshold === "number" ? conv.threshold * 100 : 66
|
|
55
|
+
);
|
|
56
|
+
const [maxRounds, setMaxRounds] = useState<number>((collab.max_rounds as number) || 5);
|
|
57
|
+
const [scopeStrategy, setScopeStrategy] = useState<string>((collab.scope_strategy as string) || "shared");
|
|
58
|
+
|
|
59
|
+
// Mesh settings
|
|
60
|
+
const [metric, setMetric] = useState(value.metric || "");
|
|
61
|
+
const [budgetMinutes, setBudgetMinutes] = useState(value.budget_minutes || 30);
|
|
62
|
+
const [scopeText, setScopeText] = useState((value.scope || []).join("\n"));
|
|
63
|
+
const [selectedNodes, setSelectedNodes] = useState<Set<string>>(
|
|
64
|
+
new Set(value.preferred_nodes || [])
|
|
65
|
+
);
|
|
66
|
+
const [clusterId, setClusterId] = useState(value.cluster_id || "");
|
|
67
|
+
|
|
68
|
+
// Available nodes
|
|
69
|
+
const { data: nodesData } = useSWR<{ nodes: Array<{ nodeId: string; status: string }> }>(
|
|
70
|
+
mode !== "local" ? "/api/mesh/nodes" : null,
|
|
71
|
+
fetcher,
|
|
72
|
+
{ refreshInterval: 10000 }
|
|
73
|
+
);
|
|
74
|
+
const nodes = nodesData?.nodes ?? [];
|
|
75
|
+
|
|
76
|
+
// Clusters (for collab mode)
|
|
77
|
+
const { clusters } = useClusters();
|
|
78
|
+
|
|
79
|
+
// Sync internal state back to parent
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
// Don't fire onChange during disabled state
|
|
82
|
+
if (disabled) return;
|
|
83
|
+
|
|
84
|
+
const scopeArr = scopeText
|
|
85
|
+
.split("\n")
|
|
86
|
+
.map((s) => s.trim())
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
|
|
89
|
+
if (mode === "local") {
|
|
90
|
+
onChange({
|
|
91
|
+
execution: "local",
|
|
92
|
+
collaboration: null,
|
|
93
|
+
preferred_nodes: [],
|
|
94
|
+
exclude_nodes: [],
|
|
95
|
+
cluster_id: null,
|
|
96
|
+
metric: null,
|
|
97
|
+
budget_minutes: 30,
|
|
98
|
+
scope: [],
|
|
99
|
+
needs_approval: true,
|
|
100
|
+
});
|
|
101
|
+
} else if (mode === "mesh") {
|
|
102
|
+
onChange({
|
|
103
|
+
execution: "mesh",
|
|
104
|
+
collaboration: null,
|
|
105
|
+
preferred_nodes: Array.from(selectedNodes),
|
|
106
|
+
exclude_nodes: [],
|
|
107
|
+
cluster_id: null,
|
|
108
|
+
metric: metric || null,
|
|
109
|
+
budget_minutes: budgetMinutes,
|
|
110
|
+
scope: scopeArr,
|
|
111
|
+
needs_approval: false,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
// collab
|
|
115
|
+
const nodeIds = Array.from(selectedNodes);
|
|
116
|
+
onChange({
|
|
117
|
+
execution: "mesh",
|
|
118
|
+
collaboration: {
|
|
119
|
+
mode: collabMode,
|
|
120
|
+
min_nodes: Math.min(nodeIds.length || 2, 2),
|
|
121
|
+
max_nodes: nodeIds.length || 4,
|
|
122
|
+
join_window_s: 30,
|
|
123
|
+
max_rounds: maxRounds,
|
|
124
|
+
convergence: {
|
|
125
|
+
type: convergenceType,
|
|
126
|
+
threshold: convergenceThreshold / 100,
|
|
127
|
+
metric: null,
|
|
128
|
+
min_quorum: Math.min(nodeIds.length || 2, 2),
|
|
129
|
+
},
|
|
130
|
+
scope_strategy: scopeStrategy,
|
|
131
|
+
},
|
|
132
|
+
preferred_nodes: nodeIds,
|
|
133
|
+
exclude_nodes: [],
|
|
134
|
+
cluster_id: clusterId || null,
|
|
135
|
+
metric: metric || null,
|
|
136
|
+
budget_minutes: budgetMinutes,
|
|
137
|
+
scope: scopeArr,
|
|
138
|
+
needs_approval: false,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
+
}, [
|
|
143
|
+
mode,
|
|
144
|
+
collabMode,
|
|
145
|
+
convergenceType,
|
|
146
|
+
convergenceThreshold,
|
|
147
|
+
maxRounds,
|
|
148
|
+
scopeStrategy,
|
|
149
|
+
metric,
|
|
150
|
+
budgetMinutes,
|
|
151
|
+
scopeText,
|
|
152
|
+
selectedNodes,
|
|
153
|
+
clusterId,
|
|
154
|
+
disabled,
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const setMode = (m: ExecutionMode) => {
|
|
158
|
+
if (disabled) return;
|
|
159
|
+
if (m === "local") {
|
|
160
|
+
onChange({
|
|
161
|
+
execution: "local",
|
|
162
|
+
collaboration: null,
|
|
163
|
+
preferred_nodes: [],
|
|
164
|
+
exclude_nodes: [],
|
|
165
|
+
cluster_id: null,
|
|
166
|
+
metric: null,
|
|
167
|
+
budget_minutes: 30,
|
|
168
|
+
scope: [],
|
|
169
|
+
needs_approval: true,
|
|
170
|
+
});
|
|
171
|
+
} else if (m === "mesh") {
|
|
172
|
+
onChange({
|
|
173
|
+
execution: "mesh",
|
|
174
|
+
collaboration: null,
|
|
175
|
+
preferred_nodes: Array.from(selectedNodes),
|
|
176
|
+
exclude_nodes: [],
|
|
177
|
+
cluster_id: null,
|
|
178
|
+
metric: metric || null,
|
|
179
|
+
budget_minutes: budgetMinutes,
|
|
180
|
+
scope: scopeText.split("\n").map((s) => s.trim()).filter(Boolean),
|
|
181
|
+
needs_approval: false,
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
// Set collab — trigger the useEffect to build collaboration spec
|
|
185
|
+
onChange({
|
|
186
|
+
execution: "mesh",
|
|
187
|
+
collaboration: { mode: collabMode }, // placeholder, useEffect rebuilds
|
|
188
|
+
preferred_nodes: Array.from(selectedNodes),
|
|
189
|
+
exclude_nodes: [],
|
|
190
|
+
cluster_id: clusterId || null,
|
|
191
|
+
metric: metric || null,
|
|
192
|
+
budget_minutes: budgetMinutes,
|
|
193
|
+
scope: scopeText.split("\n").map((s) => s.trim()).filter(Boolean),
|
|
194
|
+
needs_approval: false,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const toggleNode = (nodeId: string) => {
|
|
200
|
+
setSelectedNodes((prev) => {
|
|
201
|
+
const next = new Set(prev);
|
|
202
|
+
if (next.has(nodeId)) next.delete(nodeId);
|
|
203
|
+
else next.add(nodeId);
|
|
204
|
+
return next;
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const inputCls =
|
|
209
|
+
"w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary";
|
|
210
|
+
const btnCls = (active: boolean) =>
|
|
211
|
+
`px-2.5 py-1 text-[10px] rounded-md border transition-colors ${
|
|
212
|
+
active
|
|
213
|
+
? "border-primary bg-primary/10 text-primary"
|
|
214
|
+
: "border-border text-muted-foreground hover:text-foreground"
|
|
215
|
+
}`;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div className="space-y-3">
|
|
219
|
+
{/* Mode selector */}
|
|
220
|
+
<div className="flex gap-2">
|
|
221
|
+
{MODES.map((m) => (
|
|
222
|
+
<button
|
|
223
|
+
key={m.value}
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={() => setMode(m.value)}
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
className={btnCls(mode === m.value)}
|
|
228
|
+
>
|
|
229
|
+
{m.label}
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
{disabled && (
|
|
233
|
+
<span className="text-[10px] text-amber-400 self-center ml-auto">
|
|
234
|
+
Locked (submitted)
|
|
235
|
+
</span>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{mode !== "local" && (
|
|
240
|
+
<>
|
|
241
|
+
{/* Collab-specific settings */}
|
|
242
|
+
{mode === "collab" && (
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<div>
|
|
245
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
246
|
+
Collab Mode
|
|
247
|
+
</label>
|
|
248
|
+
<div className="flex gap-1.5">
|
|
249
|
+
{COLLAB_MODES.map((cm) => (
|
|
250
|
+
<button
|
|
251
|
+
key={cm}
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => setCollabMode(cm)}
|
|
254
|
+
className={btnCls(collabMode === cm)}
|
|
255
|
+
>
|
|
256
|
+
{cm}
|
|
257
|
+
</button>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div>
|
|
263
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
264
|
+
Convergence
|
|
265
|
+
</label>
|
|
266
|
+
<div className="flex gap-1.5">
|
|
267
|
+
{CONVERGENCE_TYPES.map((ct) => (
|
|
268
|
+
<button
|
|
269
|
+
key={ct}
|
|
270
|
+
type="button"
|
|
271
|
+
onClick={() => setConvergenceType(ct)}
|
|
272
|
+
className={btnCls(convergenceType === ct)}
|
|
273
|
+
>
|
|
274
|
+
{ct}
|
|
275
|
+
</button>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{convergenceType === "majority" && (
|
|
281
|
+
<div>
|
|
282
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
283
|
+
Threshold: {convergenceThreshold}%
|
|
284
|
+
</label>
|
|
285
|
+
<input
|
|
286
|
+
type="range"
|
|
287
|
+
min={51}
|
|
288
|
+
max={100}
|
|
289
|
+
value={convergenceThreshold}
|
|
290
|
+
onChange={(e) => setConvergenceThreshold(parseInt(e.target.value, 10))}
|
|
291
|
+
className="w-full"
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<div className="grid grid-cols-2 gap-2">
|
|
297
|
+
<div>
|
|
298
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
299
|
+
Max Rounds
|
|
300
|
+
</label>
|
|
301
|
+
<input
|
|
302
|
+
type="number"
|
|
303
|
+
min={1}
|
|
304
|
+
max={20}
|
|
305
|
+
value={maxRounds}
|
|
306
|
+
onChange={(e) => setMaxRounds(parseInt(e.target.value, 10) || 5)}
|
|
307
|
+
className={inputCls}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
<div>
|
|
311
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
312
|
+
Scope Strategy
|
|
313
|
+
</label>
|
|
314
|
+
<select
|
|
315
|
+
value={scopeStrategy}
|
|
316
|
+
onChange={(e) => setScopeStrategy(e.target.value)}
|
|
317
|
+
className={inputCls}
|
|
318
|
+
>
|
|
319
|
+
{SCOPE_STRATEGIES.map((ss) => (
|
|
320
|
+
<option key={ss} value={ss}>
|
|
321
|
+
{ss}
|
|
322
|
+
</option>
|
|
323
|
+
))}
|
|
324
|
+
</select>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* Cluster selector */}
|
|
329
|
+
{clusters.length > 0 && (
|
|
330
|
+
<div>
|
|
331
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
332
|
+
Cluster
|
|
333
|
+
</label>
|
|
334
|
+
<select
|
|
335
|
+
value={clusterId}
|
|
336
|
+
onChange={(e) => {
|
|
337
|
+
setClusterId(e.target.value);
|
|
338
|
+
if (e.target.value) {
|
|
339
|
+
const cluster = clusters.find((c) => c.id === e.target.value);
|
|
340
|
+
if (cluster) {
|
|
341
|
+
const memberIds = cluster.members.map((m) => m.nodeId);
|
|
342
|
+
setSelectedNodes(new Set(memberIds));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
className={inputCls}
|
|
347
|
+
>
|
|
348
|
+
<option value="">No cluster (manual nodes)</option>
|
|
349
|
+
{clusters.map((c) => (
|
|
350
|
+
<option key={c.id} value={c.id}>
|
|
351
|
+
{c.name} ({c.members.length} nodes)
|
|
352
|
+
</option>
|
|
353
|
+
))}
|
|
354
|
+
</select>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
|
|
360
|
+
{/* Shared mesh settings */}
|
|
361
|
+
<div className="grid grid-cols-2 gap-2">
|
|
362
|
+
<div>
|
|
363
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
364
|
+
Metric
|
|
365
|
+
</label>
|
|
366
|
+
<input
|
|
367
|
+
type="text"
|
|
368
|
+
value={metric}
|
|
369
|
+
onChange={(e) => setMetric(e.target.value)}
|
|
370
|
+
placeholder="e.g., test pass rate"
|
|
371
|
+
className={`${inputCls} font-mono`}
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
<div>
|
|
375
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
376
|
+
Budget (min)
|
|
377
|
+
</label>
|
|
378
|
+
<input
|
|
379
|
+
type="number"
|
|
380
|
+
min={5}
|
|
381
|
+
max={480}
|
|
382
|
+
value={budgetMinutes}
|
|
383
|
+
onChange={(e) => setBudgetMinutes(parseInt(e.target.value, 10) || 30)}
|
|
384
|
+
className={inputCls}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<div>
|
|
390
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
391
|
+
Scope (file paths, one per line)
|
|
392
|
+
</label>
|
|
393
|
+
<textarea
|
|
394
|
+
value={scopeText}
|
|
395
|
+
onChange={(e) => setScopeText(e.target.value)}
|
|
396
|
+
rows={2}
|
|
397
|
+
className={`${inputCls} font-mono resize-none`}
|
|
398
|
+
placeholder="src/lib/ tests/"
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Node selection */}
|
|
403
|
+
{nodes.length > 0 && (
|
|
404
|
+
<div>
|
|
405
|
+
<label className="block text-[10px] font-medium text-muted-foreground mb-1">
|
|
406
|
+
Nodes ({selectedNodes.size} selected)
|
|
407
|
+
</label>
|
|
408
|
+
<div className="flex flex-wrap gap-1.5">
|
|
409
|
+
{nodes.map((n) => (
|
|
410
|
+
<button
|
|
411
|
+
key={n.nodeId}
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={() => toggleNode(n.nodeId)}
|
|
414
|
+
className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
|
|
415
|
+
selectedNodes.has(n.nodeId)
|
|
416
|
+
? "border-cyan-500 bg-cyan-500/15 text-cyan-400"
|
|
417
|
+
: "border-border text-muted-foreground hover:text-foreground"
|
|
418
|
+
} ${n.status !== "online" ? "opacity-50" : ""}`}
|
|
419
|
+
>
|
|
420
|
+
{n.nodeId.slice(0, 12)}
|
|
421
|
+
{n.status !== "online" && ` (${n.status})`}
|
|
422
|
+
</button>
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
</>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
@@ -67,17 +67,26 @@ export function KanbanBoard() {
|
|
|
67
67
|
);
|
|
68
68
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
69
|
|
|
70
|
-
// Filter tasks
|
|
70
|
+
// Filter tasks: exclude archived + roadmap hierarchy types, then apply search
|
|
71
71
|
const filteredTasks = useMemo(() => {
|
|
72
|
-
|
|
73
|
-
const q = searchQuery.toLowerCase();
|
|
74
|
-
return tasks.filter(
|
|
72
|
+
let filtered = tasks.filter(
|
|
75
73
|
(t) =>
|
|
76
|
-
t.
|
|
77
|
-
t.
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
t.status !== "archived" &&
|
|
75
|
+
t.type !== "project" &&
|
|
76
|
+
t.type !== "phase" &&
|
|
77
|
+
t.type !== "pipeline"
|
|
80
78
|
);
|
|
79
|
+
if (searchQuery.trim()) {
|
|
80
|
+
const q = searchQuery.toLowerCase();
|
|
81
|
+
filtered = filtered.filter(
|
|
82
|
+
(t) =>
|
|
83
|
+
t.id.toLowerCase().includes(q) ||
|
|
84
|
+
t.title.toLowerCase().includes(q) ||
|
|
85
|
+
(t.description || "").toLowerCase().includes(q) ||
|
|
86
|
+
(t.owner || "").toLowerCase().includes(q)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return filtered;
|
|
81
90
|
}, [tasks, searchQuery]);
|
|
82
91
|
|
|
83
92
|
// Build parent→children map and separate top-level vs child tasks
|
|
@@ -200,10 +209,29 @@ export function KanbanBoard() {
|
|
|
200
209
|
[]
|
|
201
210
|
);
|
|
202
211
|
|
|
212
|
+
// Done-gate state
|
|
213
|
+
const [doneConfirmTaskId, setDoneConfirmTaskId] = useState<string | null>(null);
|
|
214
|
+
|
|
203
215
|
const handleMoveTask = (taskId: string, targetColumn: string) => {
|
|
216
|
+
if (targetColumn === "done") {
|
|
217
|
+
// Prompt Gui to confirm — only they can mark tasks done
|
|
218
|
+
setDoneConfirmTaskId(taskId);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
204
221
|
updateTask(taskId, { kanbanColumn: targetColumn });
|
|
205
222
|
};
|
|
206
223
|
|
|
224
|
+
const confirmDone = () => {
|
|
225
|
+
if (doneConfirmTaskId) {
|
|
226
|
+
updateTask(doneConfirmTaskId, { kanbanColumn: "done", force_done: true } as Record<string, unknown>);
|
|
227
|
+
setDoneConfirmTaskId(null);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const cancelDone = () => {
|
|
232
|
+
setDoneConfirmTaskId(null);
|
|
233
|
+
};
|
|
234
|
+
|
|
207
235
|
const handleTaskClick = (task: Task) => {
|
|
208
236
|
if (bulkMode) {
|
|
209
237
|
toggleBulkSelect(task.id);
|
|
@@ -303,6 +331,12 @@ export function KanbanBoard() {
|
|
|
303
331
|
highlight={bulkMode}
|
|
304
332
|
bulkMode={bulkMode}
|
|
305
333
|
bulkSelection={bulkSelection}
|
|
334
|
+
onClearDone={col === "done" ? async () => {
|
|
335
|
+
// Archive all visible done tasks
|
|
336
|
+
for (const t of columns.done) {
|
|
337
|
+
await updateTask(t.id, { status: "archived" } as Record<string, unknown>);
|
|
338
|
+
}
|
|
339
|
+
} : undefined}
|
|
306
340
|
/>
|
|
307
341
|
</div>
|
|
308
342
|
{i < NUM_COLS - 1 && (
|
|
@@ -331,7 +365,13 @@ export function KanbanBoard() {
|
|
|
331
365
|
))}
|
|
332
366
|
<div className="w-px h-6 bg-border" />
|
|
333
367
|
<button
|
|
334
|
-
onClick={() =>
|
|
368
|
+
onClick={() => {
|
|
369
|
+
for (const id of bulkSelection) {
|
|
370
|
+
updateTask(id, { status: "done", force_done: true } as Record<string, unknown>);
|
|
371
|
+
}
|
|
372
|
+
setBulkSelection(new Set());
|
|
373
|
+
setBulkAction(null);
|
|
374
|
+
}}
|
|
335
375
|
className="px-2.5 py-1 text-xs rounded-md bg-green-500/15 text-green-400 border border-green-500/20 hover:bg-green-500/25 transition-colors"
|
|
336
376
|
>
|
|
337
377
|
Mark Done
|
|
@@ -359,6 +399,32 @@ export function KanbanBoard() {
|
|
|
359
399
|
>
|
|
360
400
|
<Plus className="h-5 w-5" />
|
|
361
401
|
</button>
|
|
402
|
+
|
|
403
|
+
{/* Done-gate confirmation dialog */}
|
|
404
|
+
{doneConfirmTaskId && (
|
|
405
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
406
|
+
<div className="w-full max-w-sm rounded-lg border border-border bg-card shadow-xl p-5">
|
|
407
|
+
<h3 className="text-sm font-semibold text-foreground mb-2">Mark as Done?</h3>
|
|
408
|
+
<p className="text-xs text-muted-foreground mb-4">
|
|
409
|
+
Only you can mark tasks as done. Agents and nodes land in Review for your approval.
|
|
410
|
+
</p>
|
|
411
|
+
<div className="flex items-center gap-2 justify-end">
|
|
412
|
+
<button
|
|
413
|
+
onClick={cancelDone}
|
|
414
|
+
className="px-3 py-1.5 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
415
|
+
>
|
|
416
|
+
Cancel
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
onClick={confirmDone}
|
|
420
|
+
className="px-3 py-1.5 text-xs rounded-md bg-green-500/15 text-green-400 border border-green-500/20 hover:bg-green-500/25 transition-colors"
|
|
421
|
+
>
|
|
422
|
+
Confirm Done
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
362
428
|
</>
|
|
363
429
|
);
|
|
364
430
|
}
|