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.
Files changed (115) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +401 -12
  4. package/bin/mesh-bridge.js +66 -1
  5. package/bin/mesh-task-daemon.js +816 -26
  6. package/bin/mesh.js +403 -1
  7. package/config/claude-settings.json +95 -0
  8. package/config/daemon.json.template +2 -1
  9. package/config/git-hooks/pre-commit +13 -0
  10. package/config/git-hooks/pre-push +12 -0
  11. package/config/harness-rules.json +174 -0
  12. package/config/plan-templates/team-bugfix.yaml +52 -0
  13. package/config/plan-templates/team-deploy.yaml +50 -0
  14. package/config/plan-templates/team-feature.yaml +71 -0
  15. package/config/roles/qa-engineer.yaml +36 -0
  16. package/config/roles/solidity-dev.yaml +51 -0
  17. package/config/roles/tech-architect.yaml +36 -0
  18. package/config/rules/framework/solidity.md +22 -0
  19. package/config/rules/framework/typescript.md +21 -0
  20. package/config/rules/framework/unity.md +21 -0
  21. package/config/rules/universal/design-docs.md +18 -0
  22. package/config/rules/universal/git-hygiene.md +18 -0
  23. package/config/rules/universal/security.md +19 -0
  24. package/config/rules/universal/test-standards.md +19 -0
  25. package/identity/DELEGATION.md +6 -6
  26. package/install.sh +293 -8
  27. package/lib/circling-parser.js +119 -0
  28. package/lib/hyperagent-store.mjs +652 -0
  29. package/lib/kanban-io.js +9 -0
  30. package/lib/mcp-knowledge/bench.mjs +118 -0
  31. package/lib/mcp-knowledge/core.mjs +528 -0
  32. package/lib/mcp-knowledge/package.json +25 -0
  33. package/lib/mcp-knowledge/server.mjs +245 -0
  34. package/lib/mcp-knowledge/test.mjs +802 -0
  35. package/lib/memory-budget.mjs +261 -0
  36. package/lib/mesh-collab.js +301 -1
  37. package/lib/mesh-harness.js +427 -0
  38. package/lib/mesh-plans.js +13 -5
  39. package/lib/mesh-tasks.js +67 -0
  40. package/lib/plan-templates.js +226 -0
  41. package/lib/pre-compression-flush.mjs +320 -0
  42. package/lib/role-loader.js +292 -0
  43. package/lib/rule-loader.js +358 -0
  44. package/lib/session-store.mjs +458 -0
  45. package/lib/transcript-parser.mjs +292 -0
  46. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  47. package/mission-control/drizzle.config.ts +1 -4
  48. package/mission-control/package-lock.json +1571 -83
  49. package/mission-control/package.json +6 -2
  50. package/mission-control/scripts/gen-chronology.js +3 -3
  51. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  52. package/mission-control/scripts/import-pipeline.js +0 -15
  53. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  54. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  55. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  56. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  57. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  58. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  59. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  60. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  61. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  62. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  63. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  64. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  65. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  66. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  67. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  68. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  69. package/mission-control/src/app/api/tasks/route.ts +21 -30
  70. package/mission-control/src/app/cowork/page.tsx +261 -0
  71. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  72. package/mission-control/src/app/graph/page.tsx +26 -0
  73. package/mission-control/src/app/memory/page.tsx +1 -1
  74. package/mission-control/src/app/obsidian/page.tsx +36 -6
  75. package/mission-control/src/app/roadmap/page.tsx +24 -0
  76. package/mission-control/src/app/souls/page.tsx +2 -2
  77. package/mission-control/src/components/board/execution-config.tsx +431 -0
  78. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  79. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  80. package/mission-control/src/components/board/task-card.tsx +55 -2
  81. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  82. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  83. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  84. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  85. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  86. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  87. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  88. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  89. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  90. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  91. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  92. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  93. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  94. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  95. package/mission-control/src/lib/config.ts +58 -0
  96. package/mission-control/src/lib/db/index.ts +69 -0
  97. package/mission-control/src/lib/db/schema.ts +61 -3
  98. package/mission-control/src/lib/hooks.ts +309 -0
  99. package/mission-control/src/lib/memory/entities.ts +3 -2
  100. package/mission-control/src/lib/nats.ts +66 -1
  101. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  102. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  103. package/mission-control/src/lib/scheduler.ts +12 -11
  104. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  105. package/mission-control/src/lib/sync/tasks.ts +23 -1
  106. package/mission-control/src/lib/task-id.ts +32 -0
  107. package/mission-control/src/lib/tts/index.ts +33 -9
  108. package/mission-control/tsconfig.json +2 -1
  109. package/mission-control/vitest.config.ts +14 -0
  110. package/package.json +15 -2
  111. package/services/service-manifest.json +1 -1
  112. package/skills/cc-godmode/references/agents.md +8 -8
  113. package/workspace-bin/memory-daemon.mjs +199 -5
  114. package/workspace-bin/session-search.mjs +204 -0
  115. 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
- <ObsidianGraph
217
- graph={filteredGraph}
218
- isLoading={graphLoading}
219
- selectedNode={selectedPath}
220
- onNodeClick={handleNodeClick}
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": "daedalus"
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: "daedalus" }),
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/&#10;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 by search query
70
+ // Filter tasks: exclude archived + roadmap hierarchy types, then apply search
71
71
  const filteredTasks = useMemo(() => {
72
- if (!searchQuery.trim()) return tasks;
73
- const q = searchQuery.toLowerCase();
74
- return tasks.filter(
72
+ let filtered = tasks.filter(
75
73
  (t) =>
76
- t.id.toLowerCase().includes(q) ||
77
- t.title.toLowerCase().includes(q) ||
78
- (t.description || "").toLowerCase().includes(q) ||
79
- (t.owner || "").toLowerCase().includes(q)
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={() => handleBulkStatus("done")}
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
  }