@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 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={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.name,
302
- sublabel: w.active ? "running" : w.status,
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.name}
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.label
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({ name, owner, sourcePath });
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.find((w) => w.name === name && (w.owner === owner || owner === "unknown"));
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: (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown",
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, path.join(missionPath, relDir, f)),
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 the .harness/ directory for changes that affect the dashboard
14
- // snapshot — progress.json (state), progress.log (activity), actions/* (sprint
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.20",
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"