@walwal-harness/cli 7.1.19 → 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 +7 -1
- package/README.md +1 -1
- package/apps/harness-dashboard/components/OrgTree.tsx +41 -24
- package/apps/harness-dashboard/components/Scene.tsx +1 -1
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +50 -0
- package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +42 -0
- package/apps/harness-dashboard/lib/harness-state.ts +160 -6
- package/apps/harness-dashboard/lib/types.ts +6 -0
- package/apps/harness-dashboard/lib/watcher.ts +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -31,13 +31,19 @@ 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
|
+
- 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.
|
|
39
|
+
|
|
34
40
|
### Added
|
|
35
41
|
- Hourly wake can now run either `claude -p` or `codex exec` via `HARNESS_WAKE_EXECUTOR=claude|codex`.
|
|
36
42
|
- Hourly wake supports `HARNESS_WAKE_MODE=headless|tmux|record`; `tmux` creates a separate time-boxed session per hourly company tick for easier inspection.
|
|
37
43
|
- Deterministic conductor fallback runs after each hourly review so meeting decisions can advance even when the LLM runtime is unavailable.
|
|
38
44
|
- Hourly meeting minutes now split meaningful progress from paperwork-only artifacts and track strategy cadence drift.
|
|
39
45
|
|
|
40
|
-
### Changed
|
|
46
|
+
### Runtime Changed
|
|
41
47
|
- launchd wake PATH now includes `$HOME/.local/bin` so local Claude/Codex installs are found from non-interactive jobs.
|
|
42
48
|
- 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
49
|
|
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,14 +272,16 @@ 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
|
-
|
|
277
|
-
|
|
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;
|
|
278
280
|
|
|
279
281
|
return (
|
|
280
282
|
<div key={node.id} className="flex flex-col items-center gap-2">
|
|
281
283
|
<OrgCard
|
|
282
|
-
node={
|
|
284
|
+
node={displayNode}
|
|
283
285
|
size="md"
|
|
284
286
|
active={activeNodeId === node.id}
|
|
285
287
|
onClick={onNodeClick}
|
|
@@ -289,38 +291,53 @@ export function OrgTree({ snapshot, activeNodeId, onNodeClick }: OrgTreeProps) {
|
|
|
289
291
|
<>
|
|
290
292
|
<div className="w-px h-3 bg-gray-700/60" />
|
|
291
293
|
<div className="flex flex-col gap-1.5 w-full">
|
|
292
|
-
{nodeWorkers.map((w) =>
|
|
294
|
+
{nodeWorkers.map((w) => {
|
|
295
|
+
const workerNodeId = `worker-${node.role}-${w.name}`;
|
|
296
|
+
const workerActive = activeNodeId === workerNodeId || w.active;
|
|
297
|
+
return (
|
|
293
298
|
<button
|
|
294
|
-
key={w.name}
|
|
299
|
+
key={`${node.role}-${w.name}`}
|
|
295
300
|
type="button"
|
|
296
301
|
onClick={() =>
|
|
297
302
|
onNodeClick({
|
|
298
|
-
id:
|
|
303
|
+
id: workerNodeId,
|
|
299
304
|
role: node.role,
|
|
300
|
-
label: w.
|
|
301
|
-
sublabel: w.status
|
|
302
|
-
status: w.status === "COMPLETE" ? "idle" : "
|
|
303
|
-
activity: null,
|
|
305
|
+
label: w.displayName,
|
|
306
|
+
sublabel: w.active ? `running · ${w.name}` : `${w.status} · ${w.name}`,
|
|
307
|
+
status: w.active ? "typing" : w.status === "COMPLETE" ? "idle" : "queued",
|
|
308
|
+
activity: w.sourcePath ?? null,
|
|
304
309
|
agentIds: [],
|
|
305
310
|
})
|
|
306
311
|
}
|
|
307
|
-
className={`rounded border
|
|
308
|
-
|
|
309
|
-
|
|
312
|
+
className={`relative rounded border px-2 py-1.5 text-left min-w-[130px] max-w-[160px] transition-all hover:border-white/20 ${
|
|
313
|
+
workerActive
|
|
314
|
+
? "border-cyan-300/70 bg-cyan-400/10 shadow-[0_0_18px_rgba(34,211,238,0.18)]"
|
|
315
|
+
: "border-gray-700/40 bg-gray-800/30"
|
|
316
|
+
} ${activeNodeId === workerNodeId ? "ring-1 ring-cyan-300" : ""}`}
|
|
310
317
|
>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
318
|
+
{w.active && (
|
|
319
|
+
<span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-cyan-300 animate-pulse" />
|
|
320
|
+
)}
|
|
321
|
+
<div className={`font-mono text-[9px] ${w.active ? "text-cyan-300/80" : "text-gray-500"}`}>
|
|
322
|
+
hired worker
|
|
323
|
+
</div>
|
|
324
|
+
<div className="text-[11px] font-medium text-gray-200 truncate pr-3">
|
|
325
|
+
{w.displayName}
|
|
314
326
|
</div>
|
|
315
327
|
<div
|
|
316
328
|
className={`text-[9px] font-mono mt-0.5 ${
|
|
317
|
-
w.
|
|
318
|
-
|
|
329
|
+
w.active
|
|
330
|
+
? "text-cyan-300"
|
|
331
|
+
: w.status === "COMPLETE"
|
|
332
|
+
? "text-emerald-400"
|
|
333
|
+
: "text-amber-400"
|
|
334
|
+
}`}
|
|
319
335
|
>
|
|
320
|
-
{w.status}
|
|
336
|
+
{w.active ? "RUNNING" : w.status}
|
|
321
337
|
</div>
|
|
322
338
|
</button>
|
|
323
|
-
|
|
339
|
+
);
|
|
340
|
+
})}
|
|
324
341
|
</div>
|
|
325
342
|
</>
|
|
326
343
|
)}
|
|
@@ -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"
|
|
@@ -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
|
});
|
|
@@ -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?: {
|
|
@@ -775,6 +776,16 @@ function readFeatureTitles(rootDir: string): Map<string, string> {
|
|
|
775
776
|
}
|
|
776
777
|
|
|
777
778
|
type RawWorker = NonNullable<NonNullable<RawProgress["company_state"]>["workers"]>[number];
|
|
779
|
+
type WorkerOwner = WorkerDocEntry["owner"];
|
|
780
|
+
|
|
781
|
+
interface HiredWorkerEntry {
|
|
782
|
+
name: string;
|
|
783
|
+
owner: Exclude<WorkerOwner, "unknown">;
|
|
784
|
+
sourcePath: string | null;
|
|
785
|
+
mission: string | null;
|
|
786
|
+
task: string | null;
|
|
787
|
+
capability: string | null;
|
|
788
|
+
}
|
|
778
789
|
|
|
779
790
|
function deriveWorkerProgress(worker: RawWorker): number | null {
|
|
780
791
|
const status = (worker?.status ?? worker?.spawn_status ?? "").toLowerCase();
|
|
@@ -837,6 +848,109 @@ function buildWorkers(rootDir: string, progress: RawProgress | null): WorkerSnap
|
|
|
837
848
|
});
|
|
838
849
|
}
|
|
839
850
|
|
|
851
|
+
function normalizeWorkerName(value: string | null | undefined): string {
|
|
852
|
+
return (value ?? "")
|
|
853
|
+
.toLowerCase()
|
|
854
|
+
.replace(/^harness-/, "")
|
|
855
|
+
.replace(/\.md$/, "")
|
|
856
|
+
.trim();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function normalizeWorkerOwner(value: string | null | undefined): HiredWorkerEntry["owner"] | null {
|
|
860
|
+
const owner = (value ?? "").toLowerCase().replace(/^harness-/, "");
|
|
861
|
+
if (owner === "coo" || owner === "cdo" || owner === "cto" || owner === "cqo" || owner === "ops") {
|
|
862
|
+
return owner;
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function readHiredWorkers(rootDir: string): HiredWorkerEntry[] {
|
|
868
|
+
const rosterPath = path.join(rootDir, ".harness", "shared", "hr-roster.json");
|
|
869
|
+
const roster = readJsonSafe<{
|
|
870
|
+
hired?: Array<{
|
|
871
|
+
worker?: string;
|
|
872
|
+
name?: string;
|
|
873
|
+
owner?: string;
|
|
874
|
+
owningCxx?: string;
|
|
875
|
+
mission?: string;
|
|
876
|
+
task?: string;
|
|
877
|
+
capability?: string;
|
|
878
|
+
skillPath?: string;
|
|
879
|
+
skillPaths?: { source?: string };
|
|
880
|
+
}>;
|
|
881
|
+
}>(rosterPath);
|
|
882
|
+
if (!roster.ok) return [];
|
|
883
|
+
|
|
884
|
+
const seen = new Set<string>();
|
|
885
|
+
const hired: HiredWorkerEntry[] = [];
|
|
886
|
+
for (const entry of roster.value.hired ?? []) {
|
|
887
|
+
const name = normalizeWorkerName(entry.worker ?? entry.name);
|
|
888
|
+
const owner = normalizeWorkerOwner(entry.owner ?? entry.owningCxx);
|
|
889
|
+
if (!name || !owner) continue;
|
|
890
|
+
|
|
891
|
+
const sourcePath = entry.skillPaths?.source ?? entry.skillPath ?? `.harness/shared/HR-Resource/${name}/SKILL.md`;
|
|
892
|
+
const sourceAbs = path.join(rootDir, sourcePath);
|
|
893
|
+
if (!existsSync(sourceAbs)) continue;
|
|
894
|
+
|
|
895
|
+
const key = `${owner}:${name}`;
|
|
896
|
+
if (seen.has(key)) continue;
|
|
897
|
+
seen.add(key);
|
|
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
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return hired;
|
|
908
|
+
}
|
|
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
|
+
|
|
937
|
+
function activeWorkerNames(progress: RawProgress | null): Set<string> {
|
|
938
|
+
const active = new Set<string>();
|
|
939
|
+
const workers = [
|
|
940
|
+
...(progress?.company_state?.workers ?? []),
|
|
941
|
+
...(progress?.company_state?.last_dispatch ?? []),
|
|
942
|
+
];
|
|
943
|
+
for (const worker of workers) {
|
|
944
|
+
const rawStatus = (worker.status ?? worker.spawn_status ?? "").toLowerCase();
|
|
945
|
+
if (["completed", "done", "idle"].includes(rawStatus)) continue;
|
|
946
|
+
for (const value of [worker.agent, worker.feature]) {
|
|
947
|
+
const normalized = normalizeWorkerName(value);
|
|
948
|
+
if (normalized) active.add(normalized);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return active;
|
|
952
|
+
}
|
|
953
|
+
|
|
840
954
|
function meetingTitleFromMarkdown(text: string, fallback: string): string {
|
|
841
955
|
const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
842
956
|
return heading || fallback;
|
|
@@ -989,6 +1103,8 @@ function buildRuntime(progress: RawProgress | null): RuntimeSnapshot {
|
|
|
989
1103
|
function readMissions(rootDir: string, progress: RawProgress | null = null, limit = 15): MissionDoc[] {
|
|
990
1104
|
const docsDir = path.join(rootDir, ".harness", "documents");
|
|
991
1105
|
if (!existsSync(docsDir)) return [];
|
|
1106
|
+
const hiredWorkers = readHiredWorkers(rootDir);
|
|
1107
|
+
const activeWorkers = activeWorkerNames(progress);
|
|
992
1108
|
|
|
993
1109
|
const missionDirs: Array<{ rel: string; abs: string }> = [];
|
|
994
1110
|
const cxxDocNames = new Set(["ceo.md", "cto.md", "cqo.md", "coo.md", "cdo.md", "ops.md"]);
|
|
@@ -1035,20 +1151,39 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
|
|
|
1035
1151
|
};
|
|
1036
1152
|
|
|
1037
1153
|
const workers: WorkerDocEntry[] = [];
|
|
1038
|
-
const
|
|
1154
|
+
const workersByKey = new Map<string, WorkerDocEntry>();
|
|
1155
|
+
const addWorkersFromDir = (owner: WorkerOwner, relDir: string) => {
|
|
1039
1156
|
const workersDir = path.join(missionPath, relDir);
|
|
1040
1157
|
if (!existsSync(workersDir)) return;
|
|
1041
1158
|
try {
|
|
1042
1159
|
for (const f of readdirSync(workersDir)) {
|
|
1043
1160
|
if (!f.endsWith(".md")) continue;
|
|
1161
|
+
const reportAbs = path.join(missionPath, relDir, f);
|
|
1044
1162
|
const content = readMd(`${relDir}/${f}`) ?? "";
|
|
1045
1163
|
const statusMatch = content.match(/##\s*Status\s*\n+([A-Z_]+)/);
|
|
1046
|
-
|
|
1047
|
-
|
|
1164
|
+
const status = (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown";
|
|
1165
|
+
const name = normalizeWorkerName(f);
|
|
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 */ }
|
|
1174
|
+
const entry: WorkerDocEntry = {
|
|
1175
|
+
name,
|
|
1176
|
+
displayName: workerDisplayName(content, hired, name),
|
|
1048
1177
|
content,
|
|
1049
|
-
status
|
|
1050
|
-
owner,
|
|
1051
|
-
|
|
1178
|
+
status,
|
|
1179
|
+
owner: hired?.owner ?? owner,
|
|
1180
|
+
hired: !!hired,
|
|
1181
|
+
active: activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE"),
|
|
1182
|
+
sourcePath: hired?.sourcePath ?? null,
|
|
1183
|
+
reportPath: path.relative(rootDir, reportAbs),
|
|
1184
|
+
updatedAt,
|
|
1185
|
+
};
|
|
1186
|
+
workersByKey.set(`${entry.owner}:${entry.name}`, entry);
|
|
1052
1187
|
}
|
|
1053
1188
|
} catch { /* ignore */ }
|
|
1054
1189
|
};
|
|
@@ -1060,6 +1195,25 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
|
|
|
1060
1195
|
addWorkersFromDir("ops", "ops/workers");
|
|
1061
1196
|
addWorkersFromDir("unknown", "workers");
|
|
1062
1197
|
|
|
1198
|
+
for (const hired of hiredWorkers) {
|
|
1199
|
+
const key = `${hired.owner}:${hired.name}`;
|
|
1200
|
+
if (workersByKey.has(key)) continue;
|
|
1201
|
+
workersByKey.set(key, {
|
|
1202
|
+
name: hired.name,
|
|
1203
|
+
displayName: workerDisplayName("", hired, hired.name),
|
|
1204
|
+
content: "",
|
|
1205
|
+
status: activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
|
|
1206
|
+
owner: hired.owner,
|
|
1207
|
+
hired: true,
|
|
1208
|
+
active: activeWorkers.has(hired.name),
|
|
1209
|
+
sourcePath: hired.sourcePath,
|
|
1210
|
+
reportPath: null,
|
|
1211
|
+
updatedAt: null,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
workers.push(...workersByKey.values());
|
|
1216
|
+
|
|
1063
1217
|
const cxxRoles = ["ceo", "cto", "cqo", "coo", "cdo", "ops"] as const;
|
|
1064
1218
|
const cxxPresent = cxxRoles.filter(role => existsSync(path.join(missionPath, `${role}.md`)));
|
|
1065
1219
|
|
|
@@ -297,9 +297,15 @@ 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
|
+
hired: boolean;
|
|
305
|
+
active: boolean;
|
|
306
|
+
sourcePath?: string | null;
|
|
307
|
+
reportPath?: string | null;
|
|
308
|
+
updatedAt?: string | null;
|
|
303
309
|
}
|
|
304
310
|
|
|
305
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"
|