@walwal-harness/cli 7.1.19 → 7.1.20

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
@@ -31,13 +31,17 @@ docmeta:
31
31
 
32
32
  ## Unreleased
33
33
 
34
+ ### Changed
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
+ - Running hired workers are highlighted under their owning CXX instead of showing queue jobs as worker seats.
37
+
34
38
  ### Added
35
39
  - Hourly wake can now run either `claude -p` or `codex exec` via `HARNESS_WAKE_EXECUTOR=claude|codex`.
36
40
  - Hourly wake supports `HARNESS_WAKE_MODE=headless|tmux|record`; `tmux` creates a separate time-boxed session per hourly company tick for easier inspection.
37
41
  - Deterministic conductor fallback runs after each hourly review so meeting decisions can advance even when the LLM runtime is unavailable.
38
42
  - Hourly meeting minutes now split meaningful progress from paperwork-only artifacts and track strategy cadence drift.
39
43
 
40
- ### Changed
44
+ ### Runtime Changed
41
45
  - launchd wake PATH now includes `$HOME/.local/bin` so local Claude/Codex installs are found from non-interactive jobs.
42
46
  - Service-Ops records repeated incident signatures and marks repeated incidents as `recovery_required`, causing hourly meetings to route to CTO runtime recovery instead of repeating discussion.
43
47
 
package/README.md CHANGED
@@ -181,7 +181,7 @@ npx walwal-harness init --force --project-root /path/to/project
181
181
  Features:
182
182
 
183
183
  - **Split Workspace** — full-viewport left/right panes keep Org Tree + Mission Timeline beside the selected mission detail; both panes scroll independently with hidden scrollbars, and the detail pane resets to top on selection changes
184
- - **Org Tree** — live status of Owner → CEO → CXX → Workers hierarchy, with worker cards grouped under their owning CXX; clicking any node initializes the right pane to the relevant history, CXX document, or worker report
184
+ - **Org Tree** — live status of Owner → CEO → CXX → Workers hierarchy, with worker cards created only from hired `hr-roster.json` entries backed by `.harness/shared/HR-Resource/{worker}/SKILL.md`; running hired workers are highlighted under their owning CXX
185
185
  - **Mission Timeline** — clickable history of goal/submission/hot-fix missions showing the full dispatch chain
186
186
  - **Mission Flow tab** — per-mission flow: Owner prompt → CEO routing → CXX → owner-specific worker evidence → CQO verdict
187
187
  - **History tab** — mission-specific Owner request (from CEO summary + closest progress.log match)
@@ -225,9 +225,9 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
225
225
  },
226
226
  ];
227
227
 
228
- // Workers are grouped by their owning CXX so evaluator reports sit under CQO,
229
- // implementation reports under CTO, and legacy flat reports stay visible.
230
- const workers = currentMission?.workers ?? [];
228
+ // Only hired HR-Resource workers are positioned in the org chart. Legacy
229
+ // report-only workers remain visible in mission documents, but not as seats.
230
+ const workers = (currentMission?.workers ?? []).filter((w) => w.hired);
231
231
 
232
232
  return (
233
233
  <div className="w-full px-4 py-6 select-none">
@@ -272,9 +272,7 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
272
272
  {/* Row 3: CXX nodes with workers below */}
273
273
  <div className="flex gap-3 justify-center">
274
274
  {cxxNodes.map((node) => {
275
- const nodeWorkers = workers.filter(
276
- (w) => w.owner === node.role || (w.owner === "unknown" && node.role === "cto")
277
- );
275
+ const nodeWorkers = workers.filter((w) => w.owner === node.role);
278
276
 
279
277
  return (
280
278
  <div key={node.id} className="flex flex-col items-center gap-2">
@@ -289,38 +287,53 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
289
287
  <>
290
288
  <div className="w-px h-3 bg-gray-700/60" />
291
289
  <div className="flex flex-col gap-1.5 w-full">
292
- {nodeWorkers.map((w) => (
290
+ {nodeWorkers.map((w) => {
291
+ const workerNodeId = `worker-${node.role}-${w.name}`;
292
+ const workerActive = activeNodeId === workerNodeId || w.active;
293
+ return (
293
294
  <button
294
- key={w.name}
295
+ key={`${node.role}-${w.name}`}
295
296
  type="button"
296
297
  onClick={() =>
297
298
  onNodeClick({
298
- id: `worker-${w.name}`,
299
+ id: workerNodeId,
299
300
  role: node.role,
300
301
  label: w.name,
301
- sublabel: w.status,
302
- status: w.status === "COMPLETE" ? "idle" : "typing",
303
- activity: null,
302
+ sublabel: w.active ? "running" : w.status,
303
+ status: w.active ? "typing" : w.status === "COMPLETE" ? "idle" : "queued",
304
+ activity: w.sourcePath ?? null,
304
305
  agentIds: [],
305
306
  })
306
307
  }
307
- className={`rounded border border-gray-700/40 bg-gray-800/30 px-2 py-1.5 text-left min-w-[130px] max-w-[160px] transition-all hover:border-white/20 ${
308
- activeNodeId === `worker-${w.name}` ? "ring-1 ring-cyan-300" : ""
309
- }`}
308
+ className={`relative rounded border px-2 py-1.5 text-left min-w-[130px] max-w-[160px] transition-all hover:border-white/20 ${
309
+ workerActive
310
+ ? "border-cyan-300/70 bg-cyan-400/10 shadow-[0_0_18px_rgba(34,211,238,0.18)]"
311
+ : "border-gray-700/40 bg-gray-800/30"
312
+ } ${activeNodeId === workerNodeId ? "ring-1 ring-cyan-300" : ""}`}
310
313
  >
311
- <div className="font-mono text-[9px] text-gray-500">worker</div>
312
- <div className="text-[11px] font-medium text-gray-200 truncate">
314
+ {w.active && (
315
+ <span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-cyan-300 animate-pulse" />
316
+ )}
317
+ <div className={`font-mono text-[9px] ${w.active ? "text-cyan-300/80" : "text-gray-500"}`}>
318
+ hired worker
319
+ </div>
320
+ <div className="text-[11px] font-medium text-gray-200 truncate pr-3">
313
321
  {w.name}
314
322
  </div>
315
323
  <div
316
324
  className={`text-[9px] font-mono mt-0.5 ${
317
- w.status === "COMPLETE" ? "text-emerald-400" : "text-amber-400"
318
- }`}
325
+ w.active
326
+ ? "text-cyan-300"
327
+ : w.status === "COMPLETE"
328
+ ? "text-emerald-400"
329
+ : "text-amber-400"
330
+ }`}
319
331
  >
320
- {w.status}
332
+ {w.active ? "RUNNING" : w.status}
321
333
  </div>
322
334
  </button>
323
- ))}
335
+ );
336
+ })}
324
337
  </div>
325
338
  </>
326
339
  )}
@@ -247,4 +247,54 @@ describe("readHarnessState", () => {
247
247
  expect(missions[0].type).toBe("submission");
248
248
  expect(missions.find((mission) => mission.missionId === "goal-1-auth-system")?.type).toBe("goal");
249
249
  });
250
+
251
+ it("positions only hired HR-Resource workers and marks active workers", () => {
252
+ const harnessDir = path.join(dir, ".harness");
253
+ const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
254
+ mkdirSync(path.join(missionDir, "cto", "workers"), { recursive: true });
255
+ mkdirSync(path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker"), { recursive: true });
256
+ writeFileSync(path.join(missionDir, "ceo.md"), "# Dashboard\n");
257
+ writeFileSync(
258
+ path.join(missionDir, "cto", "workers", "react-ui-worker.md"),
259
+ "## Status\nIN_PROGRESS\n"
260
+ );
261
+ writeFileSync(
262
+ path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker", "SKILL.md"),
263
+ "# React UI Worker\n"
264
+ );
265
+ writeFileSync(
266
+ path.join(harnessDir, "shared", "hr-roster.json"),
267
+ JSON.stringify({
268
+ hired: [
269
+ {
270
+ worker: "react-ui-worker",
271
+ owner: "cto",
272
+ skillPath: ".harness/shared/HR-Resource/react-ui-worker/SKILL.md",
273
+ },
274
+ {
275
+ worker: "missing-worker",
276
+ owner: "cto",
277
+ skillPath: ".harness/shared/HR-Resource/missing-worker/SKILL.md",
278
+ },
279
+ ],
280
+ })
281
+ );
282
+ writeFileSync(
283
+ path.join(harnessDir, "progress.json"),
284
+ JSON.stringify({
285
+ company_state: {
286
+ workers: [{ agent: "react-ui-worker", status: "running", feature: "F1" }],
287
+ },
288
+ })
289
+ );
290
+
291
+ const workers = readHarnessState(dir).missions[0].workers;
292
+ expect(workers.map((w) => w.name)).toEqual(["react-ui-worker"]);
293
+ expect(workers[0]).toMatchObject({
294
+ owner: "cto",
295
+ hired: true,
296
+ active: true,
297
+ sourcePath: ".harness/shared/HR-Resource/react-ui-worker/SKILL.md",
298
+ });
299
+ });
250
300
  });
@@ -775,6 +775,13 @@ function readFeatureTitles(rootDir: string): Map<string, string> {
775
775
  }
776
776
 
777
777
  type RawWorker = NonNullable<NonNullable<RawProgress["company_state"]>["workers"]>[number];
778
+ type WorkerOwner = WorkerDocEntry["owner"];
779
+
780
+ interface HiredWorkerEntry {
781
+ name: string;
782
+ owner: Exclude<WorkerOwner, "unknown">;
783
+ sourcePath: string | null;
784
+ }
778
785
 
779
786
  function deriveWorkerProgress(worker: RawWorker): number | null {
780
787
  const status = (worker?.status ?? worker?.spawn_status ?? "").toLowerCase();
@@ -837,6 +844,72 @@ function buildWorkers(rootDir: string, progress: RawProgress | null): WorkerSnap
837
844
  });
838
845
  }
839
846
 
847
+ function normalizeWorkerName(value: string | null | undefined): string {
848
+ return (value ?? "")
849
+ .toLowerCase()
850
+ .replace(/^harness-/, "")
851
+ .replace(/\.md$/, "")
852
+ .trim();
853
+ }
854
+
855
+ function normalizeWorkerOwner(value: string | null | undefined): HiredWorkerEntry["owner"] | null {
856
+ const owner = (value ?? "").toLowerCase().replace(/^harness-/, "");
857
+ if (owner === "coo" || owner === "cdo" || owner === "cto" || owner === "cqo" || owner === "ops") {
858
+ return owner;
859
+ }
860
+ return null;
861
+ }
862
+
863
+ function readHiredWorkers(rootDir: string): HiredWorkerEntry[] {
864
+ const rosterPath = path.join(rootDir, ".harness", "shared", "hr-roster.json");
865
+ const roster = readJsonSafe<{
866
+ hired?: Array<{
867
+ worker?: string;
868
+ name?: string;
869
+ owner?: string;
870
+ owningCxx?: string;
871
+ skillPath?: string;
872
+ skillPaths?: { source?: string };
873
+ }>;
874
+ }>(rosterPath);
875
+ if (!roster.ok) return [];
876
+
877
+ const seen = new Set<string>();
878
+ const hired: HiredWorkerEntry[] = [];
879
+ for (const entry of roster.value.hired ?? []) {
880
+ const name = normalizeWorkerName(entry.worker ?? entry.name);
881
+ const owner = normalizeWorkerOwner(entry.owner ?? entry.owningCxx);
882
+ if (!name || !owner) continue;
883
+
884
+ const sourcePath = entry.skillPaths?.source ?? entry.skillPath ?? `.harness/shared/HR-Resource/${name}/SKILL.md`;
885
+ const sourceAbs = path.join(rootDir, sourcePath);
886
+ if (!existsSync(sourceAbs)) continue;
887
+
888
+ const key = `${owner}:${name}`;
889
+ if (seen.has(key)) continue;
890
+ seen.add(key);
891
+ hired.push({ name, owner, sourcePath });
892
+ }
893
+ return hired;
894
+ }
895
+
896
+ function activeWorkerNames(progress: RawProgress | null): Set<string> {
897
+ const active = new Set<string>();
898
+ const workers = [
899
+ ...(progress?.company_state?.workers ?? []),
900
+ ...(progress?.company_state?.last_dispatch ?? []),
901
+ ];
902
+ for (const worker of workers) {
903
+ const rawStatus = (worker.status ?? worker.spawn_status ?? "").toLowerCase();
904
+ if (["completed", "done", "idle"].includes(rawStatus)) continue;
905
+ for (const value of [worker.agent, worker.feature]) {
906
+ const normalized = normalizeWorkerName(value);
907
+ if (normalized) active.add(normalized);
908
+ }
909
+ }
910
+ return active;
911
+ }
912
+
840
913
  function meetingTitleFromMarkdown(text: string, fallback: string): string {
841
914
  const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
842
915
  return heading || fallback;
@@ -989,6 +1062,8 @@ function buildRuntime(progress: RawProgress | null): RuntimeSnapshot {
989
1062
  function readMissions(rootDir: string, progress: RawProgress | null = null, limit = 15): MissionDoc[] {
990
1063
  const docsDir = path.join(rootDir, ".harness", "documents");
991
1064
  if (!existsSync(docsDir)) return [];
1065
+ const hiredWorkers = readHiredWorkers(rootDir);
1066
+ const activeWorkers = activeWorkerNames(progress);
992
1067
 
993
1068
  const missionDirs: Array<{ rel: string; abs: string }> = [];
994
1069
  const cxxDocNames = new Set(["ceo.md", "cto.md", "cqo.md", "coo.md", "cdo.md", "ops.md"]);
@@ -1035,7 +1110,8 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1035
1110
  };
1036
1111
 
1037
1112
  const workers: WorkerDocEntry[] = [];
1038
- const addWorkersFromDir = (owner: WorkerDocEntry["owner"], relDir: string) => {
1113
+ const workersByKey = new Map<string, WorkerDocEntry>();
1114
+ const addWorkersFromDir = (owner: WorkerOwner, relDir: string) => {
1039
1115
  const workersDir = path.join(missionPath, relDir);
1040
1116
  if (!existsSync(workersDir)) return;
1041
1117
  try {
@@ -1043,12 +1119,19 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1043
1119
  if (!f.endsWith(".md")) continue;
1044
1120
  const content = readMd(`${relDir}/${f}`) ?? "";
1045
1121
  const statusMatch = content.match(/##\s*Status\s*\n+([A-Z_]+)/);
1046
- workers.push({
1047
- name: f.replace(".md", ""),
1122
+ const name = normalizeWorkerName(f);
1123
+ const hired = hiredWorkers.find((w) => w.name === name && (w.owner === owner || owner === "unknown"));
1124
+ const entry: WorkerDocEntry = {
1125
+ name,
1048
1126
  content,
1049
1127
  status: (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown",
1050
- owner,
1051
- });
1128
+ owner: hired?.owner ?? owner,
1129
+ hired: !!hired,
1130
+ active: activeWorkers.has(name),
1131
+ sourcePath: hired?.sourcePath ?? null,
1132
+ reportPath: path.relative(rootDir, path.join(missionPath, relDir, f)),
1133
+ };
1134
+ workersByKey.set(`${entry.owner}:${entry.name}`, entry);
1052
1135
  }
1053
1136
  } catch { /* ignore */ }
1054
1137
  };
@@ -1060,6 +1143,23 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1060
1143
  addWorkersFromDir("ops", "ops/workers");
1061
1144
  addWorkersFromDir("unknown", "workers");
1062
1145
 
1146
+ for (const hired of hiredWorkers) {
1147
+ const key = `${hired.owner}:${hired.name}`;
1148
+ if (workersByKey.has(key)) continue;
1149
+ workersByKey.set(key, {
1150
+ name: hired.name,
1151
+ content: "",
1152
+ status: activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
1153
+ owner: hired.owner,
1154
+ hired: true,
1155
+ active: activeWorkers.has(hired.name),
1156
+ sourcePath: hired.sourcePath,
1157
+ reportPath: null,
1158
+ });
1159
+ }
1160
+
1161
+ workers.push(...workersByKey.values());
1162
+
1063
1163
  const cxxRoles = ["ceo", "cto", "cqo", "coo", "cdo", "ops"] as const;
1064
1164
  const cxxPresent = cxxRoles.filter(role => existsSync(path.join(missionPath, `${role}.md`)));
1065
1165
 
@@ -300,6 +300,10 @@ export interface WorkerDocEntry {
300
300
  content: string;
301
301
  status: "COMPLETE" | "IN_PROGRESS" | "unknown";
302
302
  owner: "cto" | "cqo" | "coo" | "cdo" | "ops" | "unknown";
303
+ hired: boolean;
304
+ active: boolean;
305
+ sourcePath?: string | null;
306
+ reportPath?: string | null;
303
307
  }
304
308
 
305
309
  export interface MissionDoc {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.19",
3
+ "version": "7.1.20",
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"