@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 +5 -1
- package/README.md +1 -1
- package/apps/harness-dashboard/components/OrgTree.tsx +34 -21
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +50 -0
- package/apps/harness-dashboard/lib/harness-state.ts +105 -5
- package/apps/harness-dashboard/lib/types.ts +4 -0
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
229
|
-
//
|
|
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:
|
|
299
|
+
id: workerNodeId,
|
|
299
300
|
role: node.role,
|
|
300
301
|
label: w.name,
|
|
301
|
-
sublabel: w.status,
|
|
302
|
-
status: w.status === "COMPLETE" ? "idle" : "
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1047
|
-
|
|
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.
|
|
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"
|