@walwal-harness/cli 7.1.8 → 7.1.9

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.
Files changed (66) hide show
  1. package/README.md +40 -0
  2. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  3. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  4. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  5. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  6. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  7. package/apps/harness-dashboard/app/globals.css +17 -0
  8. package/apps/harness-dashboard/app/layout.tsx +29 -0
  9. package/apps/harness-dashboard/app/page.tsx +35 -0
  10. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  11. package/apps/harness-dashboard/components/Header.tsx +40 -0
  12. package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
  13. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  14. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  15. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  16. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  17. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  18. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  19. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  20. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  21. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
  22. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
  23. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  24. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  25. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  26. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  27. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  28. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  29. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  30. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  31. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  32. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  33. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  34. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  35. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  36. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  37. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  38. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  39. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  40. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  41. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  42. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  43. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  44. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  45. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  46. package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
  47. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  48. package/apps/harness-dashboard/lib/iso.ts +150 -0
  49. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  50. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  51. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  52. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  53. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  54. package/apps/harness-dashboard/lib/types.ts +329 -0
  55. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  56. package/apps/harness-dashboard/next-env.d.ts +6 -0
  57. package/apps/harness-dashboard/next.config.mjs +9 -0
  58. package/apps/harness-dashboard/package-lock.json +4280 -0
  59. package/apps/harness-dashboard/package.json +38 -0
  60. package/apps/harness-dashboard/playwright.config.ts +23 -0
  61. package/apps/harness-dashboard/postcss.config.js +6 -0
  62. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  63. package/apps/harness-dashboard/tsconfig.json +24 -0
  64. package/apps/harness-dashboard/vitest.config.ts +15 -0
  65. package/package.json +11 -1
  66. package/scripts/harness-dashboard-up.sh +24 -4
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { deriveMinifigState } from "../state-mapping";
3
+
4
+ describe("deriveMinifigState", () => {
5
+ it("returns idle when there is no progress data", () => {
6
+ expect(deriveMinifigState("planner", null)).toBe("idle");
7
+ });
8
+
9
+ it("returns typing when agent is current and running", () => {
10
+ expect(
11
+ deriveMinifigState("generator-backend", {
12
+ current_agent: "generator-backend",
13
+ agent_status: "running",
14
+ })
15
+ ).toBe("typing");
16
+ });
17
+
18
+ it("does not return typing when agent_status is not running", () => {
19
+ expect(
20
+ deriveMinifigState("generator-backend", {
21
+ current_agent: "generator-backend",
22
+ agent_status: "completed",
23
+ })
24
+ ).toBe("idle");
25
+ });
26
+
27
+ it("returns queued when agent is assigned as next_agent", () => {
28
+ expect(
29
+ deriveMinifigState("cto", {
30
+ current_agent: "planner",
31
+ next_agent: "cto",
32
+ agent_status: "completed",
33
+ })
34
+ ).toBe("queued");
35
+ });
36
+
37
+ it("returns talking when agent is in meetings.active", () => {
38
+ expect(
39
+ deriveMinifigState("planner", {
40
+ meetings: { active: ["planner", "dispatcher"] },
41
+ })
42
+ ).toBe("talking");
43
+ });
44
+
45
+ it("talking wins over typing when both apply", () => {
46
+ expect(
47
+ deriveMinifigState("planner", {
48
+ current_agent: "planner",
49
+ agent_status: "running",
50
+ meetings: { active: ["planner"] },
51
+ })
52
+ ).toBe("talking");
53
+ });
54
+
55
+ it("red-alert wins over everything", () => {
56
+ expect(
57
+ deriveMinifigState("evaluator-functional", {
58
+ current_agent: "evaluator-functional",
59
+ agent_status: "running",
60
+ meetings: { active: ["evaluator-functional"] },
61
+ failure: { agent: "evaluator-functional", message: "regression" },
62
+ })
63
+ ).toBe("red-alert");
64
+ });
65
+
66
+ it("Service-Ops always goes red-alert when any incident is open", () => {
67
+ // Even if the incident's dept is unknown, Service-Ops is the on-call
68
+ // department and should reflect the alert state.
69
+ expect(
70
+ deriveMinifigState(
71
+ "service-ops",
72
+ { current_agent: null, agent_status: "idle" },
73
+ { dept: "Operations", incidentDepts: new Set(["CTO"]) }
74
+ )
75
+ ).toBe("red-alert");
76
+ });
77
+
78
+ it("dept-matching incident lights up the dept's agents", () => {
79
+ expect(
80
+ deriveMinifigState(
81
+ "generator-backend",
82
+ { current_agent: null },
83
+ { dept: "CTO", incidentDepts: new Set(["CTO"]) }
84
+ )
85
+ ).toBe("red-alert");
86
+ });
87
+
88
+ it("agents in unrelated dept stay idle when incident is elsewhere", () => {
89
+ expect(
90
+ deriveMinifigState(
91
+ "evaluator-visual",
92
+ { current_agent: null },
93
+ { dept: "CQO", incidentDepts: new Set(["CTO"]) }
94
+ )
95
+ ).toBe("idle");
96
+ });
97
+
98
+ it("v6 parallel: conductor.tracks running owners are typing", () => {
99
+ expect(
100
+ deriveMinifigState("planner", {
101
+ current_agent: "coo-developer",
102
+ agent_status: "running",
103
+ conductor: {
104
+ tracks: [
105
+ { owner: "planner", status: "running" },
106
+ { owner: "documentationer", status: "completed" },
107
+ ],
108
+ },
109
+ })
110
+ ).toBe("typing");
111
+ });
112
+
113
+ it("v6 parallel: conductor.tracks pending owners are queued", () => {
114
+ expect(
115
+ deriveMinifigState("documentationer", {
116
+ current_agent: "planner",
117
+ agent_status: "running",
118
+ conductor: {
119
+ tracks: [
120
+ { owner: "planner", status: "running" },
121
+ { owner: "documentationer", status: "pending" },
122
+ ],
123
+ },
124
+ })
125
+ ).toBe("queued");
126
+ });
127
+
128
+ it("v6 parallel: conductor.current_action spawn target is typing", () => {
129
+ // current_agent has finished the previous tick; the next agent appears in
130
+ // current_action and must already light up so the office never goes dark.
131
+ expect(
132
+ deriveMinifigState("generator-backend", {
133
+ current_agent: "cto",
134
+ agent_status: "completed",
135
+ conductor: { current_action: "spawn:generator-backend (foundation F-101)" },
136
+ })
137
+ ).toBe("typing");
138
+ });
139
+
140
+ it("G-006: service-ops with stream_active=true is typing", () => {
141
+ expect(
142
+ deriveMinifigState("service-ops", {
143
+ current_agent: "generator-frontend",
144
+ agent_status: "running",
145
+ service_ops: { monitor: { stream_active: true, stream_target: "generator-frontend" } },
146
+ })
147
+ ).toBe("typing");
148
+ });
149
+
150
+ it("agents not involved stay idle (default-to-desk handled in 3D)", () => {
151
+ expect(
152
+ deriveMinifigState("evaluator-architecture", {
153
+ current_agent: "generator-backend",
154
+ agent_status: "running",
155
+ conductor: { tracks: [{ owner: "planner", status: "running" }] },
156
+ })
157
+ ).toBe("idle");
158
+ });
159
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { watchHarness } from "../watcher";
6
+
7
+ describe("watchHarness", () => {
8
+ let dir: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(path.join(tmpdir(), "watch-"));
12
+ mkdirSync(path.join(dir, ".harness", "actions"), { recursive: true });
13
+ mkdirSync(path.join(dir, ".harness", "archive"), { recursive: true });
14
+ writeFileSync(path.join(dir, ".harness", "progress.json"), "{}");
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ });
20
+
21
+ it("fires onChange when progress.json is rewritten", async () => {
22
+ const events: string[] = [];
23
+ const handle = watchHarness(dir, (p) => events.push(p), { debounceMs: 30 });
24
+
25
+ // Wait for chokidar's initial scan to settle.
26
+ await new Promise((r) => setTimeout(r, 250));
27
+
28
+ writeFileSync(
29
+ path.join(dir, ".harness", "progress.json"),
30
+ JSON.stringify({ updated: true })
31
+ );
32
+
33
+ // Wait long enough for awaitWriteFinish stabilityThreshold (100) +
34
+ // debounce (30) + a little slack.
35
+ await new Promise((r) => setTimeout(r, 400));
36
+
37
+ await handle.close();
38
+ expect(events.length).toBeGreaterThan(0);
39
+ expect(events.some((e) => e.endsWith("progress.json"))).toBe(true);
40
+ });
41
+
42
+ it("close() releases the watcher cleanly", async () => {
43
+ const handle = watchHarness(dir, () => {});
44
+ await new Promise((r) => setTimeout(r, 100));
45
+ await handle.close();
46
+ // No assertion — test passes if close() resolves without throwing.
47
+ });
48
+ });
@@ -0,0 +1,42 @@
1
+ import type { AgentId, Dept, RoomId } from "./types";
2
+
3
+ export interface RosterEntry {
4
+ id: AgentId;
5
+ name: string;
6
+ dept: Dept;
7
+ room: RoomId;
8
+ }
9
+
10
+ // SoT for the office roster. Mirrors v6 NEXUS organization in CLAUDE.md.
11
+ // Order within a room dictates desk assignment (see lib/furniture.ts).
12
+ export const AGENT_ROSTER: RosterEntry[] = [
13
+ { id: "dispatcher", name: "CEO (Dispatcher)", dept: "CEO", room: "ceo" },
14
+ { id: "brainstormer", name: "Brainstormer", dept: "CEO", room: "ceo" },
15
+ { id: "meeting-manager", name: "Meeting Manager", dept: "Meeting", room: "meeting" },
16
+ { id: "planner", name: "COO (Planner)", dept: "Planner", room: "coo" },
17
+ { id: "coo-developer", name: "Developer 1 (Hypothesis Cell)", dept: "Planner", room: "coo" },
18
+ { id: "documentationer", name: "Documentationer 1 (Hypothesis Cell)", dept: "Planner", room: "coo" },
19
+ { id: "cto", name: "CTO (Gen 총괄)", dept: "CTO", room: "cto-team" },
20
+ { id: "conductor", name: "Conductor", dept: "CTO", room: "cto-team" },
21
+ { id: "generator-backend", name: "Generator — Backend", dept: "CTO", room: "cto-team" },
22
+ { id: "generator-frontend", name: "Generator — Frontend", dept: "CTO", room: "cto-team" },
23
+ { id: "generator-designer", name: "Designer", dept: "CTO", room: "cto-team" },
24
+ { id: "generator-devops", name: "DevOps", dept: "CTO", room: "cto-team" },
25
+ { id: "cqo", name: "CQO (Eval 총괄)", dept: "CQO", room: "cqo-team" },
26
+ { id: "evaluator-functional", name: "Eval — Functional", dept: "CQO", room: "cqo-team" },
27
+ { id: "evaluator-visual", name: "Eval — Visual", dept: "CQO", room: "cqo-team" },
28
+ { id: "evaluator-code-quality", name: "Eval — Code Quality", dept: "CQO", room: "cqo-team" },
29
+ { id: "evaluator-architecture", name: "Eval — Architecture", dept: "CQO", room: "cqo-team" },
30
+ { id: "evaluator-security", name: "Eval — Security", dept: "CQO", room: "cqo-team" },
31
+ { id: "service-ops", name: "Service Ops", dept: "Operations", room: "service-ops" },
32
+ ];
33
+
34
+ export const ROOM_LABELS: Record<RoomId, { ko: string; en: string; dept: Dept | "Archive" }> = {
35
+ ceo: { ko: "CEO실", en: "CEO Office", dept: "CEO" },
36
+ meeting: { ko: "회의실", en: "Meeting Room", dept: "Meeting" },
37
+ coo: { ko: "COO실", en: "COO Office", dept: "Planner" },
38
+ "cto-team": { ko: "CTO팀", en: "CTO Team", dept: "CTO" },
39
+ "cqo-team": { ko: "CQO팀", en: "CQO Team", dept: "CQO" },
40
+ "service-ops": { ko: "Service-Ops", en: "Service Ops", dept: "Operations" },
41
+ archive: { ko: "아카이브", en: "Archive", dept: "Archive" },
42
+ };
@@ -0,0 +1,165 @@
1
+ import { ROOM_RECTS, type RoomRect } from "./iso";
2
+ import type { RoomId } from "./types";
3
+ import { AGENT_ROSTER } from "./agent-roster";
4
+
5
+ // How many desks each room should physically contain. The meeting room is
6
+ // over-provisioned because anyone can drop in. Room desks must accommodate the
7
+ // full roster in agent-roster.ts (CEO+Brainstormer; CTO+Conductor+4 Generators;
8
+ // CQO+5 Evaluators).
9
+ export const ROOM_CAPACITY: Record<RoomId, number> = {
10
+ ceo: 2,
11
+ meeting: 7,
12
+ coo: 3,
13
+ "cto-team": 6,
14
+ "cqo-team": 6,
15
+ "service-ops": 1,
16
+ archive: 0,
17
+ };
18
+
19
+ export interface DeskSlot {
20
+ index: number;
21
+ // Desk position (slab top center) in world coords.
22
+ desk: [number, number, number];
23
+ // Where the seated character stands (chair position).
24
+ chair: [number, number, number];
25
+ // Facing direction (yaw, radians) — character faces the desk.
26
+ facing: number;
27
+ }
28
+
29
+ const DESK_INSET = 0.55; // distance from room edge to desk
30
+ const DESK_SPACING_MIN = 0.85;
31
+
32
+ // Layout desks in two rows along the room's long axis, each row hugging one
33
+ // of the long walls. Chairs sit on the inside of each row.
34
+ export function deskSlotsForRoom(rect: RoomRect): DeskSlot[] {
35
+ const cap = ROOM_CAPACITY[rect.id];
36
+ if (cap === 0) return [];
37
+
38
+ const slots: DeskSlot[] = [];
39
+ const longAxisIsX = rect.ww >= rect.wh;
40
+ const longLen = longAxisIsX ? rect.ww : rect.wh;
41
+ const shortLen = longAxisIsX ? rect.wh : rect.ww;
42
+
43
+ // 2 rows if room is deep enough, else 1 row.
44
+ const rows = shortLen >= 2.0 && cap >= 3 ? 2 : 1;
45
+ const perRow = Math.ceil(cap / rows);
46
+ const usable = longLen - DESK_INSET * 2;
47
+ const spacing = perRow > 1 ? Math.max(DESK_SPACING_MIN, usable / perRow) : 0;
48
+ const startLong = rect.wx + (longAxisIsX ? DESK_INSET + spacing / 2 : 0);
49
+ const startLongZ = rect.wy + (!longAxisIsX ? DESK_INSET + spacing / 2 : 0);
50
+
51
+ for (let i = 0; i < cap; i++) {
52
+ const row = i % rows;
53
+ const col = Math.floor(i / rows);
54
+ const longOffset = perRow > 1 ? col * spacing : longLen / 2;
55
+
56
+ let dx: number;
57
+ let dz: number;
58
+ let cx: number;
59
+ let cz: number;
60
+ let facing: number;
61
+
62
+ if (longAxisIsX) {
63
+ dx = (startLong as number) + longOffset;
64
+ // Row 0 hugs the +z wall, row 1 hugs the -z wall.
65
+ const wallZ =
66
+ row === 0 ? rect.wy + rect.wh - DESK_INSET : rect.wy + DESK_INSET;
67
+ dz = wallZ;
68
+ cx = dx;
69
+ cz = row === 0 ? wallZ - 0.55 : wallZ + 0.55;
70
+ facing = row === 0 ? Math.PI : 0; // face the wall (Roblox cubicle vibe)
71
+ } else {
72
+ dz = (startLongZ as number) + longOffset;
73
+ const wallX =
74
+ row === 0 ? rect.wx + rect.ww - DESK_INSET : rect.wx + DESK_INSET;
75
+ dx = wallX;
76
+ cx = row === 0 ? wallX - 0.55 : wallX + 0.55;
77
+ cz = dz;
78
+ facing = row === 0 ? -Math.PI / 2 : Math.PI / 2;
79
+ }
80
+
81
+ slots.push({
82
+ index: i,
83
+ desk: [dx, 0.2, dz],
84
+ chair: [cx, 0.2, cz],
85
+ facing,
86
+ });
87
+ }
88
+
89
+ return slots;
90
+ }
91
+
92
+ // Stable mapping: which desk slot each agent owns in their *home* room.
93
+ // Built once at module load from AGENT_ROSTER.
94
+ const agentHomeDesk: Record<string, DeskSlot> = (() => {
95
+ const out: Record<string, DeskSlot> = {};
96
+ const counters = new Map<RoomId, number>();
97
+ for (const entry of AGENT_ROSTER) {
98
+ const slots = deskSlotsForRoom(
99
+ ROOM_RECTS.find((r) => r.id === entry.room)!
100
+ );
101
+ if (slots.length === 0) continue;
102
+ const idx = counters.get(entry.room) ?? 0;
103
+ out[entry.id] = slots[idx % slots.length];
104
+ counters.set(entry.room, idx + 1);
105
+ }
106
+ return out;
107
+ })();
108
+
109
+ export function homeDeskFor(agentId: string): DeskSlot | undefined {
110
+ return agentHomeDesk[agentId];
111
+ }
112
+
113
+ // Door positions per room — used by Floor3D to leave gaps in the walls and by
114
+ // the meeting-room teleport logic. We pick the side that opens onto the
115
+ // largest neighbor footprint so the office reads as "all connected".
116
+ export interface DoorSpec {
117
+ side: "north" | "south" | "east" | "west";
118
+ // World position of the door center.
119
+ position: [number, number];
120
+ width: number;
121
+ }
122
+
123
+ export function doorsForRoom(rect: RoomRect): DoorSpec[] {
124
+ const w = 1.0;
125
+ const cx = rect.wx + rect.ww / 2;
126
+ const cz = rect.wy + rect.wh / 2;
127
+ const doors: DoorSpec[] = [];
128
+
129
+ // Every room has a door on every side that opens onto a corridor — the
130
+ // corridor mesh is drawn around all rooms in Floor3D.
131
+ switch (rect.id) {
132
+ case "ceo":
133
+ doors.push({ side: "east", position: [rect.wx + rect.ww, cz], width: w });
134
+ doors.push({ side: "south", position: [cx, rect.wy + rect.wh], width: w });
135
+ break;
136
+ case "meeting":
137
+ doors.push({ side: "west", position: [rect.wx, cz], width: w });
138
+ doors.push({ side: "east", position: [rect.wx + rect.ww, cz], width: w });
139
+ doors.push({ side: "south", position: [cx, rect.wy + rect.wh], width: w });
140
+ break;
141
+ case "coo":
142
+ doors.push({ side: "west", position: [rect.wx, cz], width: w });
143
+ doors.push({ side: "south", position: [cx, rect.wy + rect.wh], width: w });
144
+ break;
145
+ case "cto-team":
146
+ doors.push({ side: "north", position: [cx, rect.wy], width: w });
147
+ doors.push({ side: "east", position: [rect.wx + rect.ww, cz], width: w });
148
+ doors.push({ side: "south", position: [cx, rect.wy + rect.wh], width: w });
149
+ break;
150
+ case "cqo-team":
151
+ doors.push({ side: "north", position: [cx, rect.wy], width: w });
152
+ doors.push({ side: "west", position: [rect.wx, cz], width: w });
153
+ doors.push({ side: "south", position: [cx, rect.wy + rect.wh], width: w });
154
+ break;
155
+ case "service-ops":
156
+ doors.push({ side: "north", position: [cx, rect.wy], width: w });
157
+ doors.push({ side: "east", position: [rect.wx + rect.ww, cz], width: w });
158
+ break;
159
+ case "archive":
160
+ doors.push({ side: "north", position: [cx, rect.wy], width: w });
161
+ doors.push({ side: "west", position: [rect.wx, cz], width: w });
162
+ break;
163
+ }
164
+ return doors;
165
+ }
@@ -0,0 +1,20 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Resolves the directory that contains `.harness/`. Search order:
5
+ // 1. env HARNESS_ROOT (absolute path) — escape hatch for tests/CI.
6
+ // 2. Walk up from cwd until we find a `.harness/` directory.
7
+ // 3. Fall back to cwd (caller will see errorBanner via readHarnessState).
8
+ export function resolveHarnessRoot(start: string = process.cwd()): string {
9
+ const fromEnv = process.env.HARNESS_ROOT;
10
+ if (fromEnv && existsSync(path.join(fromEnv, ".harness"))) {
11
+ return fromEnv;
12
+ }
13
+ let current = path.resolve(start);
14
+ while (true) {
15
+ if (existsSync(path.join(current, ".harness"))) return current;
16
+ const parent = path.dirname(current);
17
+ if (parent === current) return start;
18
+ current = parent;
19
+ }
20
+ }