@walwal-harness/cli 7.1.20 → 7.1.21
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/CHANGELOG.md +2 -0
- package/apps/harness-dashboard/components/OrgTree.tsx +8 -4
- package/apps/harness-dashboard/components/Scene.tsx +1 -1
- package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +42 -0
- package/apps/harness-dashboard/lib/harness-state.ts +59 -5
- package/apps/harness-dashboard/lib/types.ts +2 -0
- package/apps/harness-dashboard/lib/watcher.ts +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,8 @@ docmeta:
|
|
|
34
34
|
### Changed
|
|
35
35
|
- Dashboard Org Tree now positions only hired HR-Resource workers from `hr-roster.json`, requiring a backing `.harness/shared/HR-Resource/{worker}/SKILL.md`.
|
|
36
36
|
- Running hired workers are highlighted under their owning CXX instead of showing queue jobs as worker seats.
|
|
37
|
+
- Dashboard SSE now watches mission documents, hired-worker roster, and HR-Resource changes so worker updates appear without browser refresh.
|
|
38
|
+
- Dashboard worker cards now prefer mission/report task labels and mark recently updated unfinished worker reports as active when runtime active-worker state is absent.
|
|
37
39
|
|
|
38
40
|
### Added
|
|
39
41
|
- Hourly wake can now run either `claude -p` or `codex exec` via `HARNESS_WAKE_EXECUTOR=claude|codex`.
|
|
@@ -273,11 +273,15 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
|
|
|
273
273
|
<div className="flex gap-3 justify-center">
|
|
274
274
|
{cxxNodes.map((node) => {
|
|
275
275
|
const nodeWorkers = workers.filter((w) => w.owner === node.role);
|
|
276
|
+
const hasActiveWorker = nodeWorkers.some((w) => w.active);
|
|
277
|
+
const displayNode = hasActiveWorker
|
|
278
|
+
? { ...node, status: "typing" as const, activity: "Worker active" }
|
|
279
|
+
: node;
|
|
276
280
|
|
|
277
281
|
return (
|
|
278
282
|
<div key={node.id} className="flex flex-col items-center gap-2">
|
|
279
283
|
<OrgCard
|
|
280
|
-
node={
|
|
284
|
+
node={displayNode}
|
|
281
285
|
size="md"
|
|
282
286
|
active={activeNodeId === node.id}
|
|
283
287
|
onClick={onNodeClick}
|
|
@@ -298,8 +302,8 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
|
|
|
298
302
|
onNodeClick({
|
|
299
303
|
id: workerNodeId,
|
|
300
304
|
role: node.role,
|
|
301
|
-
label: w.
|
|
302
|
-
sublabel: w.active ?
|
|
305
|
+
label: w.displayName,
|
|
306
|
+
sublabel: w.active ? `running · ${w.name}` : `${w.status} · ${w.name}`,
|
|
303
307
|
status: w.active ? "typing" : w.status === "COMPLETE" ? "idle" : "queued",
|
|
304
308
|
activity: w.sourcePath ?? null,
|
|
305
309
|
agentIds: [],
|
|
@@ -318,7 +322,7 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
|
|
|
318
322
|
hired worker
|
|
319
323
|
</div>
|
|
320
324
|
<div className="text-[11px] font-medium text-gray-200 truncate pr-3">
|
|
321
|
-
{w.
|
|
325
|
+
{w.displayName}
|
|
322
326
|
</div>
|
|
323
327
|
<div
|
|
324
328
|
className={`text-[9px] font-mono mt-0.5 ${
|
|
@@ -77,7 +77,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
|
|
|
77
77
|
selectedAgent?.id ?? "",
|
|
78
78
|
].join(":");
|
|
79
79
|
const selectedWorkerName = selectedNode?.id.startsWith("worker-")
|
|
80
|
-
? selectedNode.
|
|
80
|
+
? selectedNode.id.replace(/^worker-[^-]+-/, "")
|
|
81
81
|
: undefined;
|
|
82
82
|
const selectedDocumentRole = selectedWorkerName
|
|
83
83
|
? "worker"
|
|
@@ -11,7 +11,10 @@ describe("watchHarness", () => {
|
|
|
11
11
|
dir = mkdtempSync(path.join(tmpdir(), "watch-"));
|
|
12
12
|
mkdirSync(path.join(dir, ".harness", "actions"), { recursive: true });
|
|
13
13
|
mkdirSync(path.join(dir, ".harness", "archive"), { recursive: true });
|
|
14
|
+
mkdirSync(path.join(dir, ".harness", "documents"), { recursive: true });
|
|
15
|
+
mkdirSync(path.join(dir, ".harness", "shared", "HR-Resource"), { recursive: true });
|
|
14
16
|
writeFileSync(path.join(dir, ".harness", "progress.json"), "{}");
|
|
17
|
+
writeFileSync(path.join(dir, ".harness", "shared", "hr-roster.json"), "{\"hired\":[]}");
|
|
15
18
|
});
|
|
16
19
|
|
|
17
20
|
afterEach(() => {
|
|
@@ -45,4 +48,43 @@ describe("watchHarness", () => {
|
|
|
45
48
|
await handle.close();
|
|
46
49
|
// No assertion — test passes if close() resolves without throwing.
|
|
47
50
|
});
|
|
51
|
+
|
|
52
|
+
it("fires onChange when hired workers or mission documents change", async () => {
|
|
53
|
+
const events: string[] = [];
|
|
54
|
+
const handle = watchHarness(dir, (p) => events.push(p), { debounceMs: 30 });
|
|
55
|
+
|
|
56
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
57
|
+
|
|
58
|
+
writeFileSync(
|
|
59
|
+
path.join(dir, ".harness", "shared", "hr-roster.json"),
|
|
60
|
+
JSON.stringify({ hired: [{ worker: "react-ui-worker", owner: "cto" }] })
|
|
61
|
+
);
|
|
62
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
63
|
+
|
|
64
|
+
mkdirSync(
|
|
65
|
+
path.join(dir, ".harness", "shared", "HR-Resource", "react-ui-worker"),
|
|
66
|
+
{ recursive: true }
|
|
67
|
+
);
|
|
68
|
+
writeFileSync(
|
|
69
|
+
path.join(dir, ".harness", "shared", "HR-Resource", "react-ui-worker", "SKILL.md"),
|
|
70
|
+
"# React UI Worker\n"
|
|
71
|
+
);
|
|
72
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
73
|
+
|
|
74
|
+
mkdirSync(
|
|
75
|
+
path.join(dir, ".harness", "documents", "goal-1", "cto", "workers"),
|
|
76
|
+
{ recursive: true }
|
|
77
|
+
);
|
|
78
|
+
writeFileSync(
|
|
79
|
+
path.join(dir, ".harness", "documents", "goal-1", "cto", "workers", "react-ui-worker.md"),
|
|
80
|
+
"## Status\nIN_PROGRESS\n"
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
+
|
|
85
|
+
await handle.close();
|
|
86
|
+
expect(events.some((e) => e.endsWith("hr-roster.json"))).toBe(true);
|
|
87
|
+
expect(events.some((e) => e.endsWith("SKILL.md"))).toBe(true);
|
|
88
|
+
expect(events.some((e) => e.endsWith("react-ui-worker.md"))).toBe(true);
|
|
89
|
+
});
|
|
48
90
|
});
|
|
@@ -41,6 +41,7 @@ import type {
|
|
|
41
41
|
|
|
42
42
|
const SNAPSHOT_VERSION = "1.2.0";
|
|
43
43
|
const GOAL_DESC_TRUNCATE = 200;
|
|
44
|
+
const RECENT_WORKER_ACTIVE_MS = 10 * 60 * 1000;
|
|
44
45
|
|
|
45
46
|
interface RawProgress {
|
|
46
47
|
goals?: {
|
|
@@ -781,6 +782,9 @@ interface HiredWorkerEntry {
|
|
|
781
782
|
name: string;
|
|
782
783
|
owner: Exclude<WorkerOwner, "unknown">;
|
|
783
784
|
sourcePath: string | null;
|
|
785
|
+
mission: string | null;
|
|
786
|
+
task: string | null;
|
|
787
|
+
capability: string | null;
|
|
784
788
|
}
|
|
785
789
|
|
|
786
790
|
function deriveWorkerProgress(worker: RawWorker): number | null {
|
|
@@ -868,6 +872,9 @@ function readHiredWorkers(rootDir: string): HiredWorkerEntry[] {
|
|
|
868
872
|
name?: string;
|
|
869
873
|
owner?: string;
|
|
870
874
|
owningCxx?: string;
|
|
875
|
+
mission?: string;
|
|
876
|
+
task?: string;
|
|
877
|
+
capability?: string;
|
|
871
878
|
skillPath?: string;
|
|
872
879
|
skillPaths?: { source?: string };
|
|
873
880
|
}>;
|
|
@@ -888,11 +895,45 @@ function readHiredWorkers(rootDir: string): HiredWorkerEntry[] {
|
|
|
888
895
|
const key = `${owner}:${name}`;
|
|
889
896
|
if (seen.has(key)) continue;
|
|
890
897
|
seen.add(key);
|
|
891
|
-
hired.push({
|
|
898
|
+
hired.push({
|
|
899
|
+
name,
|
|
900
|
+
owner,
|
|
901
|
+
sourcePath,
|
|
902
|
+
mission: entry.mission ?? null,
|
|
903
|
+
task: entry.task ?? null,
|
|
904
|
+
capability: entry.capability ?? null,
|
|
905
|
+
});
|
|
892
906
|
}
|
|
893
907
|
return hired;
|
|
894
908
|
}
|
|
895
909
|
|
|
910
|
+
function hiredForMission(
|
|
911
|
+
hiredWorkers: HiredWorkerEntry[],
|
|
912
|
+
name: string,
|
|
913
|
+
owner: WorkerOwner,
|
|
914
|
+
missionId: string,
|
|
915
|
+
): HiredWorkerEntry | undefined {
|
|
916
|
+
const candidates = hiredWorkers.filter(
|
|
917
|
+
(w) => w.name === name && (w.owner === owner || owner === "unknown")
|
|
918
|
+
);
|
|
919
|
+
return candidates.find((w) => w.mission === missionId) ?? candidates[0];
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function workerDisplayName(content: string, hired: HiredWorkerEntry | undefined, fallback: string): string {
|
|
923
|
+
const title = content.match(/^\s*title:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim();
|
|
924
|
+
if (title) return truncateText(title.replace(/\s+—\s+Worker Report$/i, ""), 42);
|
|
925
|
+
|
|
926
|
+
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
927
|
+
if (heading) return truncateText(heading.replace(/\s+—\s+.*$/i, ""), 42);
|
|
928
|
+
|
|
929
|
+
if (hired?.task) {
|
|
930
|
+
const capability = hired.capability ? ` · ${hired.capability}` : "";
|
|
931
|
+
return truncateText(`${hired.task}${capability}`, 42);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return fallback;
|
|
935
|
+
}
|
|
936
|
+
|
|
896
937
|
function activeWorkerNames(progress: RawProgress | null): Set<string> {
|
|
897
938
|
const active = new Set<string>();
|
|
898
939
|
const workers = [
|
|
@@ -1117,19 +1158,30 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
|
|
|
1117
1158
|
try {
|
|
1118
1159
|
for (const f of readdirSync(workersDir)) {
|
|
1119
1160
|
if (!f.endsWith(".md")) continue;
|
|
1161
|
+
const reportAbs = path.join(missionPath, relDir, f);
|
|
1120
1162
|
const content = readMd(`${relDir}/${f}`) ?? "";
|
|
1121
1163
|
const statusMatch = content.match(/##\s*Status\s*\n+([A-Z_]+)/);
|
|
1164
|
+
const status = (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown";
|
|
1122
1165
|
const name = normalizeWorkerName(f);
|
|
1123
|
-
const hired = hiredWorkers
|
|
1166
|
+
const hired = hiredForMission(hiredWorkers, name, owner, rel);
|
|
1167
|
+
let updatedAt: string | null = null;
|
|
1168
|
+
let recentlyTouched = false;
|
|
1169
|
+
try {
|
|
1170
|
+
const mtime = statSync(reportAbs).mtime;
|
|
1171
|
+
updatedAt = mtime.toISOString();
|
|
1172
|
+
recentlyTouched = Date.now() - mtime.getTime() < RECENT_WORKER_ACTIVE_MS;
|
|
1173
|
+
} catch { /* ignore */ }
|
|
1124
1174
|
const entry: WorkerDocEntry = {
|
|
1125
1175
|
name,
|
|
1176
|
+
displayName: workerDisplayName(content, hired, name),
|
|
1126
1177
|
content,
|
|
1127
|
-
status
|
|
1178
|
+
status,
|
|
1128
1179
|
owner: hired?.owner ?? owner,
|
|
1129
1180
|
hired: !!hired,
|
|
1130
|
-
active: activeWorkers.has(name),
|
|
1181
|
+
active: activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE"),
|
|
1131
1182
|
sourcePath: hired?.sourcePath ?? null,
|
|
1132
|
-
reportPath: path.relative(rootDir,
|
|
1183
|
+
reportPath: path.relative(rootDir, reportAbs),
|
|
1184
|
+
updatedAt,
|
|
1133
1185
|
};
|
|
1134
1186
|
workersByKey.set(`${entry.owner}:${entry.name}`, entry);
|
|
1135
1187
|
}
|
|
@@ -1148,6 +1200,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
|
|
|
1148
1200
|
if (workersByKey.has(key)) continue;
|
|
1149
1201
|
workersByKey.set(key, {
|
|
1150
1202
|
name: hired.name,
|
|
1203
|
+
displayName: workerDisplayName("", hired, hired.name),
|
|
1151
1204
|
content: "",
|
|
1152
1205
|
status: activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
|
|
1153
1206
|
owner: hired.owner,
|
|
@@ -1155,6 +1208,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
|
|
|
1155
1208
|
active: activeWorkers.has(hired.name),
|
|
1156
1209
|
sourcePath: hired.sourcePath,
|
|
1157
1210
|
reportPath: null,
|
|
1211
|
+
updatedAt: null,
|
|
1158
1212
|
});
|
|
1159
1213
|
}
|
|
1160
1214
|
|
|
@@ -297,6 +297,7 @@ export interface HarnessSnapshot {
|
|
|
297
297
|
// Real harness mission documents
|
|
298
298
|
export interface WorkerDocEntry {
|
|
299
299
|
name: string;
|
|
300
|
+
displayName: string;
|
|
300
301
|
content: string;
|
|
301
302
|
status: "COMPLETE" | "IN_PROGRESS" | "unknown";
|
|
302
303
|
owner: "cto" | "cqo" | "coo" | "cdo" | "ops" | "unknown";
|
|
@@ -304,6 +305,7 @@ export interface WorkerDocEntry {
|
|
|
304
305
|
active: boolean;
|
|
305
306
|
sourcePath?: string | null;
|
|
306
307
|
reportPath?: string | null;
|
|
308
|
+
updatedAt?: string | null;
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
export interface MissionDoc {
|
|
@@ -10,9 +10,8 @@ export interface WatchOptions {
|
|
|
10
10
|
debounceMs?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// Watches
|
|
14
|
-
//
|
|
15
|
-
// artifacts), archive/* (sprint history).
|
|
13
|
+
// Watches .harness paths that affect the dashboard snapshot: runtime progress,
|
|
14
|
+
// actions, mission documents, hired-worker roster/resources, and archive state.
|
|
16
15
|
export function watchHarness(
|
|
17
16
|
rootDir: string,
|
|
18
17
|
onChange: (changedPath: string) => void,
|
|
@@ -25,6 +24,9 @@ export function watchHarness(
|
|
|
25
24
|
path.join(harnessDir, "progress.json"),
|
|
26
25
|
path.join(harnessDir, "progress.log"),
|
|
27
26
|
path.join(harnessDir, "actions"),
|
|
27
|
+
path.join(harnessDir, "documents"),
|
|
28
|
+
path.join(harnessDir, "shared", "hr-roster.json"),
|
|
29
|
+
path.join(harnessDir, "shared", "HR-Resource"),
|
|
28
30
|
path.join(harnessDir, "archive"),
|
|
29
31
|
],
|
|
30
32
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walwal-harness/cli",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.21",
|
|
4
4
|
"description": "Company-style AI agent harness for Claude and Codex. Installs commands, CXX agents, skills, HR-Resource hiring pool, and project-local .harness runtime state.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"walwal-harness": "bin/init.js"
|