@walwal-harness/cli 7.1.28 → 7.1.30

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/README.md CHANGED
@@ -55,7 +55,21 @@ What `init` installs:
55
55
  | `.harness/events.jsonl` | Structured append-only runtime event stream |
56
56
  | `.harness/todos/state.json` | Machine-readable CXX todo queues |
57
57
  | `.harness/todos/events.jsonl` | Structured todo transition history |
58
- | `AGENTS.md` ← `CLAUDE.md` symlink | Project harness config |
58
+ | `AGENTS.md` + `CLAUDE.md` symlink | Project AI operating rules |
59
+
60
+ ---
61
+
62
+ ## Project Docs Merge
63
+
64
+ `AGENTS.md` is the source of truth for project AI operating rules. `CLAUDE.md` is normalized to a symlink that points at `AGENTS.md`.
65
+
66
+ Install and migrate preserve existing project instructions:
67
+
68
+ - Existing `AGENTS.md` is not replaced. The harness adds or updates a marked section between `<!-- walwal-harness:agents:start -->` and `<!-- walwal-harness:agents:end -->`.
69
+ - The top of `AGENTS.md` gets a small walwal-harness enabled marker so agents know the harness section applies.
70
+ - Existing `CLAUDE.md` file content is appended into `AGENTS.md` under a `preserved-claude` marked section, then `CLAUDE.md` is replaced with a symlink to `AGENTS.md`.
71
+ - Re-running `init`, `init --force`, or `migrate` is idempotent: marked sections are replaced, not duplicated.
72
+ - Existing docs and symlink targets are backed up under `.harness/archive/pre-harness-backup/` or the migration backup directory before modification.
59
73
 
60
74
  ---
61
75
 
@@ -70,6 +84,7 @@ Owner /goal → CEO → [COO] → [CDO] → CTO → [dev workers] → CQO → [e
70
84
  1. CEO reads Owner request, writes `ceo.md`, routes to relevant CXX.
71
85
  2. Each CXX writes its own `{cxx}.md`, hires workers, collects evidence.
72
86
  3. CEO aggregates CXX outputs and reports to Owner.
87
+ 4. Every goal/submission/hot-fix mission has `mission-state.json` with `lifecycle` and `active`.
73
88
 
74
89
  ### Hot Fix
75
90
 
@@ -181,6 +196,12 @@ bash scripts/harness-company-complete.sh . mission-complete
181
196
 
182
197
  This sets `company_state.state = "idle"`, `conductor.state = "completed"`, `next_agent = "none"`, and `owner_prompt.status = "completed"` so dashboards, runners, and hooks agree that auto-mode can stop.
183
198
 
199
+ It also closes structured work state:
200
+
201
+ - active/pending/paused todos in `.harness/todos/state.json` become `done`
202
+ - active `mission-state.json` files become `{"lifecycle":"complete","active":false}`
203
+ - the dashboard stops treating recent worker report mtimes as live activity after runtime completion
204
+
184
205
  Existing users should update and migrate from the target project:
185
206
 
186
207
  ```bash
@@ -204,6 +225,8 @@ npx walwal-harness init --force --project-root /path/to/project
204
225
  Features:
205
226
 
206
227
  - **Company Harness Observatory** — glass/aurora process monitor showing active CXX todos, workers, incidents, missions, and current loop state
228
+ - **Layer Activity by CXX/Worker** — Layer Activity rows match the heatmap hierarchy, grouping each CXX with its workers and sorting groups by remaining work first, completed groups next, empty CXX groups last
229
+ - **Explicit Mission Lifecycle** — goal/submission/hot-fix directories use `mission-state.json`; `init`, `init --force`, and `migrate` backfill missing lifecycle files for existing projects
207
230
  - **Structured Runtime State** — `/goal`, `/submission`, and `/hot-fix` append to `.harness/events.jsonl` and update `.harness/todos/state.json`; `migrate` backfills these files for existing projects
208
231
  - **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
209
232
  - **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
@@ -259,6 +282,7 @@ harness-hiring → register and onboard worker
259
282
 
260
283
  | Version | Summary |
261
284
  |---|---|
285
+ | 7.1.29 | Idempotent AGENTS/CLAUDE doc merge with marked harness sections, mission lifecycle backfill for init/migrate, completion closes todos and mission-state, and Layer Activity now lists CXX/worker rows sorted by remaining work |
262
286
  | 7.1.28 | Dashboard APM rewrite: top header consolidation, Knowledge Base panel (gotchas/conventions), Layer Activity (CEO/CXX/Worker TODO·DONE·Remain via goal-scope inference), Recent Report list (worker mtime desc), Cadence strip with click-to-prompt tooltip in local timezone, heatmap click-tooltip with empty-cell guard, Document Viewer expand-to-50vw with docmeta hidden, Command Log bubble popover |
263
287
  | 7.1.19 | `migrate` now carries OPS verification rules into existing installs: runtime.verification merge, HARNESS refresh, and AGENTS migration block |
264
288
  | 7.1.18 | OPS watches CQO runnable verification and production incidents; Implementation Notes stay in the same role/worker report |
@@ -47,8 +47,12 @@ export async function GET(request: Request) {
47
47
  send();
48
48
 
49
49
  const handle = watchHarness(root, () => send());
50
+ // Local-only data source — push a fresh snapshot every second on top
51
+ // of file-watch events so the dashboard never goes more than 1s stale.
52
+ const tick = setInterval(() => send(), 1000);
50
53
 
51
54
  const cleanup = async () => {
55
+ clearInterval(tick);
52
56
  try {
53
57
  await handle.close();
54
58
  } catch {
@@ -86,11 +86,22 @@ const CXX_TEXT: Record<CxxRole, string> = {
86
86
 
87
87
  export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
88
88
  const { snapshot, connectionState } = useHarnessStream(initial);
89
- const [selectedMissionId, setSelectedMissionId] = useState(snapshot.missions[0]?.missionId ?? null);
89
+ const initialActiveMission = snapshot.missions.find((mission) => mission.active) ?? snapshot.missions[0] ?? null;
90
+ const [selectedMissionId, setSelectedMissionId] = useState(initialActiveMission?.missionId ?? null);
90
91
  const selectedMission =
91
92
  snapshot.missions.find((mission) => mission.missionId === selectedMissionId) ??
93
+ snapshot.missions.find((mission) => mission.active) ??
92
94
  snapshot.missions[0] ??
93
95
  null;
96
+ // Owner-direct prompts only — strip auto-generated <task-notification> pings
97
+ // (used by the autonomous wake-up loop, not by the owner).
98
+ const ownerPrompts = useMemo(
99
+ () =>
100
+ snapshot.ownerHistory.filter(
101
+ (entry) => !(entry.content ?? "").trimStart().startsWith("<task-notification")
102
+ ),
103
+ [snapshot.ownerHistory]
104
+ );
94
105
  const goalGroups = useMemo(() => groupMissions(snapshot.missions), [snapshot.missions]);
95
106
  const selectedGoal = useMemo(() => {
96
107
  if (!selectedMission) return goalGroups[0]?.goal ?? null;
@@ -115,21 +126,54 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
115
126
  useEffect(() => {
116
127
  const tick = () => {
117
128
  const { lanes, snapshot, selectedMission } = sampleSourceRef.current;
129
+ // Gate live samples on the harness runtime — once the session reports
130
+ // completion (or there's no current/next agent), every "active" signal
131
+ // is stale (todos stay status="active", worker mtimes stay recent), so
132
+ // we stop emitting in-progress samples until something resumes.
133
+ const rt = snapshot.runtime;
134
+ const harnessIdle =
135
+ rt?.agentStatus === "completed" ||
136
+ rt?.agentStatus === "complete" ||
137
+ ((rt?.currentAgent ?? null) === null &&
138
+ (rt?.nextAgent === null || rt?.nextAgent === "none"));
139
+ if (harnessIdle) {
140
+ const nowMinute = Math.floor(Date.now() / BUCKET_MS);
141
+ setHeatSamples((prev) =>
142
+ prev.filter((sample) => Math.floor(sample.ts / BUCKET_MS) < nowMinute)
143
+ );
144
+ return;
145
+ }
118
146
  setHeatSamples((prev) => {
119
147
  const now = Date.now();
120
148
  const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
121
149
  const next: HeatSample[] = [];
122
150
  for (const lane of lanes) {
123
- const activity =
124
- lane.kind === "worker"
125
- ? lane.worker?.active
126
- ? 2
127
- : lane.worker?.status === "IN_PROGRESS"
128
- ? 1
129
- : 0
130
- : lane.todos +
131
- lane.workers.filter((w) => w.active).length +
132
- (lane.mission ? 1 : 0);
151
+ // 3-state activity:
152
+ // 0 = none (not on this mission)
153
+ // 1 = standby (participated / has output, no live work)
154
+ // 2 = in-progress (actively running right now)
155
+ let activity = 0;
156
+ if (lane.kind === "worker") {
157
+ const w = lane.worker;
158
+ if (w) {
159
+ if (w.active || w.status === "IN_PROGRESS") activity = 2;
160
+ else if (w.content || w.status === "COMPLETE") activity = 1;
161
+ }
162
+ } else {
163
+ const presentInMission =
164
+ lane.mission?.cxxPresent.includes(lane.role) ?? false;
165
+ const liveTodos = snapshot.todos.filter(
166
+ (t) =>
167
+ t.owner === lane.role &&
168
+ t.status !== "done" &&
169
+ t.status !== "completed"
170
+ ).length;
171
+ const runningWorkers = lane.workers.filter(
172
+ (w) => w.active || w.status === "IN_PROGRESS"
173
+ ).length;
174
+ if (liveTodos > 0 && runningWorkers === 0) activity = 2;
175
+ else if (presentInMission || lane.workers.length > 0 || runningWorkers > 0) activity = 1;
176
+ }
133
177
  if (activity <= 0) continue;
134
178
  next.push({
135
179
  ts: now,
@@ -146,7 +190,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
146
190
  });
147
191
  };
148
192
  tick();
149
- const id = setInterval(tick, 2000);
193
+ const id = setInterval(tick, 1000);
150
194
  return () => clearInterval(id);
151
195
  }, []);
152
196
  const { cells: heatmap, totalBuckets } = useMemo(
@@ -159,13 +203,23 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
159
203
  const [tooltip, setTooltip] = useState<{ mission: MissionDoc; x: number; y: number } | null>(null);
160
204
  const [commandLogOpen, setCommandLogOpen] = useState(false);
161
205
  const [docExpanded, setDocExpanded] = useState(false);
206
+ const [customDoc, setCustomDoc] = useState<{
207
+ source: string;
208
+ title: string;
209
+ content: string;
210
+ } | null>(null);
211
+
212
+ // Selecting a mission or lane returns the viewer to mission-context mode.
213
+ useEffect(() => {
214
+ setCustomDoc(null);
215
+ }, [selectedMissionId, selectedLaneId]);
162
216
 
163
217
  void lang;
164
218
 
165
219
  const selectedLane = lanes.find((lane) => lane.id === selectedLaneId) ?? lanes[0] ?? null;
166
220
  const selectedCells = getSelectedCells(heatmap, dragStart, dragEnd);
167
221
  const activeWorkers =
168
- snapshot.missions[0]?.workers.filter((w) => w.active).length ?? 0;
222
+ (snapshot.missions.find((mission) => mission.active) ?? snapshot.missions[0])?.workers.filter((w) => w.active).length ?? 0;
169
223
  const hotfixCount = snapshot.missions.filter((m) => m.type === "hotfix").length;
170
224
 
171
225
  const handleSelectWorker = (mission: MissionDoc, worker: WorkerDocEntry) => {
@@ -186,7 +240,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
186
240
  <div className="flex min-h-0 flex-1">
187
241
  <HistoryNavigator
188
242
  missions={snapshot.missions}
189
- ownerHistory={snapshot.ownerHistory}
243
+ ownerHistory={ownerPrompts}
190
244
  activeMissionId={selectedMission?.missionId ?? null}
191
245
  onSelect={setSelectedMissionId}
192
246
  commandLogOpen={commandLogOpen}
@@ -201,6 +255,13 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
201
255
  gotchas={snapshot.gotchas}
202
256
  conventions={snapshot.conventions}
203
257
  baselineTs={selectedGoal?.ts ?? null}
258
+ onSelect={(entry, kind) =>
259
+ setCustomDoc({
260
+ source: kind,
261
+ title: `${entry.id}.md`,
262
+ content: entry.content,
263
+ })
264
+ }
204
265
  />
205
266
  <LayerActivityPanel
206
267
  goalScope={goalScopeMissions}
@@ -215,7 +276,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
215
276
 
216
277
  {/* Heatmap section with cadence strip + main grid + click tooltip */}
217
278
  <section className="panel-shell relative flex min-h-0 flex-1 flex-col gap-2 p-2">
218
- <CadenceStrip ownerHistory={snapshot.ownerHistory} />
279
+ <CadenceStrip ownerHistory={ownerPrompts} />
219
280
  <WorkflowHeatmap
220
281
  lanes={lanes}
221
282
  cells={heatmap}
@@ -263,6 +324,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
263
324
  <DocumentViewer
264
325
  mission={selectedMission}
265
326
  lane={selectedLane}
327
+ customDoc={customDoc}
266
328
  expanded={docExpanded}
267
329
  onToggle={() => setDocExpanded((v) => !v)}
268
330
  />
@@ -629,14 +691,18 @@ function formatTime(iso: string | null | undefined) {
629
691
 
630
692
  // ===== Knowledge base panel =====
631
693
 
694
+ type KnowledgeKind = "Gotcha" | "Convention";
695
+
632
696
  function KnowledgeBasePanel({
633
697
  gotchas,
634
698
  conventions,
635
699
  baselineTs,
700
+ onSelect,
636
701
  }: {
637
702
  gotchas: GotchaEntry[];
638
703
  conventions: ConventionEntry[];
639
704
  baselineTs: string | null;
705
+ onSelect: (entry: GotchaEntry | ConventionEntry, kind: KnowledgeKind) => void;
640
706
  }) {
641
707
  const baseline = baselineTs ? new Date(baselineTs).getTime() : 0;
642
708
  const isNew = (updatedAt?: string | null) => {
@@ -653,19 +719,23 @@ function KnowledgeBasePanel({
653
719
  <div className="mt-2 grid min-h-0 flex-1 grid-cols-2 gap-2">
654
720
  <KnowledgeList
655
721
  title="Gotchas"
722
+ kind="Gotcha"
656
723
  total={gotchas.length}
657
724
  added={newGotchas}
658
725
  entries={gotchas}
659
726
  isNew={isNew}
660
727
  dirRel=".harness/gotchas"
728
+ onSelect={onSelect}
661
729
  />
662
730
  <KnowledgeList
663
731
  title="Conventions"
732
+ kind="Convention"
664
733
  total={conventions.length}
665
734
  added={newConventions}
666
735
  entries={conventions}
667
736
  isNew={isNew}
668
737
  dirRel=".harness/conventions"
738
+ onSelect={onSelect}
669
739
  />
670
740
  </div>
671
741
  </div>
@@ -674,18 +744,22 @@ function KnowledgeBasePanel({
674
744
 
675
745
  function KnowledgeList({
676
746
  title,
747
+ kind,
677
748
  total,
678
749
  added,
679
750
  entries,
680
751
  isNew,
681
752
  dirRel,
753
+ onSelect,
682
754
  }: {
683
755
  title: string;
756
+ kind: KnowledgeKind;
684
757
  total: number;
685
758
  added: number;
686
759
  entries: Array<GotchaEntry | ConventionEntry>;
687
760
  isNew: (ts?: string | null) => boolean;
688
761
  dirRel: string;
762
+ onSelect: (entry: GotchaEntry | ConventionEntry, kind: KnowledgeKind) => void;
689
763
  }) {
690
764
  return (
691
765
  <div className="inset-shell flex min-h-0 flex-col p-2">
@@ -706,14 +780,16 @@ function KnowledgeList({
706
780
  <div className="text-slate-500">(empty)</div>
707
781
  ) : (
708
782
  entries.map((entry) => (
709
- <a
783
+ <button
710
784
  key={entry.id}
711
- className="block cursor-pointer truncate text-slate-300 hover:text-cyan-200"
785
+ type="button"
786
+ onClick={() => onSelect(entry, kind)}
787
+ className="block w-full cursor-pointer truncate rounded px-1 py-0.5 text-left text-slate-300 transition-colors hover:bg-cyan-400/5 hover:text-cyan-200"
712
788
  title={`${dirRel}/${entry.id}.md`}
713
789
  >
714
790
  → {entry.id}.md
715
791
  {isNew(entry.updatedAt) && <span className="text-emerald-300"> ●</span>}
716
- </a>
792
+ </button>
717
793
  ))
718
794
  )}
719
795
  </div>
@@ -755,14 +831,19 @@ function LayerActivityPanel({
755
831
  () => computeGoalScopeLayers(goalScope, todos),
756
832
  [goalScope, todos]
757
833
  );
834
+ const visibleTotals = useMemo(() => {
835
+ const total = layer.ceo.total + layer.agents.reduce((sum, agent) => sum + agent.total, 0);
836
+ const done = layer.ceo.done + layer.agents.reduce((sum, agent) => sum + agent.done, 0);
837
+ return { total, done, remain: total - done };
838
+ }, [layer]);
758
839
 
759
840
  const activeCxxCount = Array.from(workerByCxx.values()).filter((v) => v > 0).length;
760
841
 
761
842
  return (
762
- <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
843
+ <div className="panel-shell fade-in flex h-full min-h-0 flex-col overflow-hidden p-2">
763
844
  <SectionLabel>Layer · {label}</SectionLabel>
764
845
 
765
- <div className="inset-shell mt-2 p-2">
846
+ <div className="inset-shell mt-2 shrink-0 p-2">
766
847
  <div className="flex items-center justify-between font-mono text-[9px] text-slate-500">
767
848
  <span>실무자 비중 (worker by CXX, CEO 제외)</span>
768
849
  <span>
@@ -798,7 +879,7 @@ function LayerActivityPanel({
798
879
  </div>
799
880
  </div>
800
881
 
801
- <div className="inset-shell mt-2 min-h-0 flex-1 overflow-auto">
882
+ <div className="inset-shell mt-2 h-0 min-h-[96px] flex-1 overflow-y-auto overflow-x-hidden">
802
883
  <table className="w-full text-[10px]">
803
884
  <thead className="sticky top-0 bg-slate-900/60 font-mono text-[9px] uppercase tracking-wider">
804
885
  <tr>
@@ -810,18 +891,38 @@ function LayerActivityPanel({
810
891
  </thead>
811
892
  <tbody>
812
893
  <LayerRow label="CEO" stats={layer.ceo} />
813
- <LayerRow label="CXX" stats={layer.cxx} />
814
- <LayerRow label="Worker" stats={layer.worker} />
894
+ {layer.agents.map((agent) => (
895
+ <tr key={agent.id} className="border-t border-slate-800/70 bg-slate-950/30">
896
+ <td
897
+ className={`max-w-0 truncate px-2 py-1 ${
898
+ agent.kind === "cxx" ? "font-semibold text-cyan-200" : "pl-5 text-slate-400"
899
+ }`}
900
+ title={agent.id}
901
+ >
902
+ {agent.kind === "worker" ? "└ " : ""}
903
+ {agent.label}
904
+ </td>
905
+ <td className="px-2 py-1 text-right text-slate-300">
906
+ {agent.total}
907
+ </td>
908
+ <td className="px-2 py-1 text-right text-emerald-300">
909
+ {agent.done}
910
+ </td>
911
+ <td className={`px-2 py-1 text-right ${agent.remain > 0 ? "text-orange-300" : "text-slate-600"}`}>
912
+ {agent.remain}
913
+ </td>
914
+ </tr>
915
+ ))}
815
916
  <tr className="border-t border-slate-800 bg-slate-900/40">
816
917
  <td className="px-2 py-1 font-semibold text-slate-300">∑</td>
817
918
  <td className="px-2 py-1 text-right font-semibold text-slate-100">
818
- {layer.ceo.total + layer.cxx.total + layer.worker.total}
919
+ {visibleTotals.total}
819
920
  </td>
820
921
  <td className="px-2 py-1 text-right font-semibold text-emerald-300">
821
- {layer.ceo.done + layer.cxx.done + layer.worker.done}
922
+ {visibleTotals.done}
822
923
  </td>
823
924
  <td className="px-2 py-1 text-right font-semibold text-orange-300">
824
- {layer.ceo.remain + layer.cxx.remain + layer.worker.remain}
925
+ {visibleTotals.remain}
825
926
  </td>
826
927
  </tr>
827
928
  </tbody>
@@ -852,10 +953,31 @@ function normalizeKind(s: string) {
852
953
  return s.replace(/[_-]/g, "").toLowerCase();
853
954
  }
854
955
 
956
+ function isTerminalLifecycle(lifecycle: MissionDoc["lifecycle"]) {
957
+ return (
958
+ lifecycle === "closed" ||
959
+ lifecycle === "cancelled" ||
960
+ lifecycle === "superseded" ||
961
+ lifecycle === "complete"
962
+ );
963
+ }
964
+
855
965
  function computeGoalScopeLayers(
856
966
  goalScope: MissionDoc[],
857
967
  todos: CxxTodo[]
858
- ): { ceo: LayerStats; cxx: LayerStats; worker: LayerStats } {
968
+ ): {
969
+ ceo: LayerStats;
970
+ cxx: LayerStats;
971
+ worker: LayerStats;
972
+ agents: Array<{
973
+ id: string;
974
+ label: string;
975
+ kind: "cxx" | "worker";
976
+ total: number;
977
+ done: number;
978
+ remain: number;
979
+ }>;
980
+ } {
859
981
  // === CEO layer: ceo todos (status.json) ↔ mission directories of matching kind ===
860
982
  // Runtime rarely flips status to "done", so infer completion from how many
861
983
  // mission directories of each kind already exist under the goal scope.
@@ -895,11 +1017,71 @@ function computeGoalScopeLayers(
895
1017
  let wDone = 0;
896
1018
  for (const m of goalScope) {
897
1019
  wTotal += m.workers.length;
898
- wDone += m.workers.filter((w) => w.status === "COMPLETE").length;
1020
+ wDone += isTerminalLifecycle(m.lifecycle)
1021
+ ? m.workers.length
1022
+ : m.workers.filter((w) => w.status === "COMPLETE").length;
899
1023
  }
900
1024
  const worker: LayerStats = { total: wTotal, done: wDone, remain: wTotal - wDone };
901
1025
 
902
- return { ceo, cxx, worker };
1026
+ const agentRows: Array<{
1027
+ id: string;
1028
+ label: string;
1029
+ kind: "cxx" | "worker";
1030
+ total: number;
1031
+ done: number;
1032
+ remain: number;
1033
+ }> = [];
1034
+ const groupedRows = WORKER_OWNERS.map((role, index) => {
1035
+ const roleMissions = goalScope.filter(
1036
+ (m) => m.cxxPresent.includes(role) || m.workers.some((w) => w.owner === role)
1037
+ );
1038
+ const roleTotal = roleMissions.length;
1039
+ const roleIncomplete = roleMissions.filter(
1040
+ (m) =>
1041
+ !isTerminalLifecycle(m.lifecycle) &&
1042
+ m.workers.some((w) => w.owner === role && w.status !== "COMPLETE")
1043
+ ).length;
1044
+ const rows: typeof agentRows = [];
1045
+ rows.push({
1046
+ id: role,
1047
+ label: role.toUpperCase(),
1048
+ kind: "cxx",
1049
+ total: roleTotal,
1050
+ done: Math.max(0, roleTotal - roleIncomplete),
1051
+ remain: roleIncomplete,
1052
+ });
1053
+
1054
+ const workersByName = new Map<string, { label: string; total: number; done: number }>();
1055
+ for (const mission of goalScope) {
1056
+ for (const worker of mission.workers.filter((w) => w.owner === role)) {
1057
+ const entry =
1058
+ workersByName.get(worker.name) ??
1059
+ { label: worker.displayName || worker.name, total: 0, done: 0 };
1060
+ entry.total += 1;
1061
+ if (isTerminalLifecycle(mission.lifecycle) || worker.status === "COMPLETE") {
1062
+ entry.done += 1;
1063
+ }
1064
+ workersByName.set(worker.name, entry);
1065
+ }
1066
+ }
1067
+ for (const [name, entry] of workersByName) {
1068
+ rows.push({
1069
+ id: `${role}:${name}`,
1070
+ label: entry.label,
1071
+ kind: "worker",
1072
+ total: entry.total,
1073
+ done: entry.done,
1074
+ remain: Math.max(0, entry.total - entry.done),
1075
+ });
1076
+ }
1077
+ const priority = roleTotal === 0 ? 2 : roleIncomplete > 0 ? 0 : 1;
1078
+ return { priority, index, rows };
1079
+ });
1080
+ groupedRows
1081
+ .sort((a, b) => a.priority - b.priority || a.index - b.index)
1082
+ .forEach((group) => agentRows.push(...group.rows));
1083
+
1084
+ return { ceo, cxx, worker, agents: agentRows };
903
1085
  }
904
1086
 
905
1087
  // ===== Recent report panel =====
@@ -1522,11 +1704,13 @@ const MARKDOWN_COMPONENTS: Components = {
1522
1704
  function DocumentViewer({
1523
1705
  mission,
1524
1706
  lane,
1707
+ customDoc,
1525
1708
  expanded,
1526
1709
  onToggle,
1527
1710
  }: {
1528
1711
  mission: MissionDoc | null;
1529
1712
  lane: AgentLane | null;
1713
+ customDoc: { source: string; title: string; content: string } | null;
1530
1714
  expanded: boolean;
1531
1715
  onToggle: () => void;
1532
1716
  }) {
@@ -1535,20 +1719,24 @@ function DocumentViewer({
1535
1719
  | undefined;
1536
1720
  const roleDoc = role ? mission?.[role] : null;
1537
1721
  const workerDoc = lane?.worker?.content ?? null;
1538
- const rawContent = workerDoc ?? roleDoc ?? mission?.ceo ?? "_No document selected._";
1722
+ const rawContent = customDoc
1723
+ ? customDoc.content
1724
+ : workerDoc ?? roleDoc ?? mission?.ceo ?? "_No document selected._";
1539
1725
  // Hide the leading YAML frontmatter (docmeta block) — keep only the body.
1540
1726
  const content = rawContent.replace(/^---[\s\S]*?---\n+/, "");
1541
- const source = workerDoc
1542
- ? `${lane?.worker?.displayName ?? "worker"}`
1543
- : role
1544
- ? role.toUpperCase()
1545
- : "CEO";
1727
+ const subtitle = customDoc
1728
+ ? `${customDoc.source} · ${customDoc.title}`
1729
+ : `${mission?.missionId ?? "select /goal or /submission"} · ${
1730
+ workerDoc
1731
+ ? lane?.worker?.displayName ?? "worker"
1732
+ : role
1733
+ ? role.toUpperCase()
1734
+ : "CEO"
1735
+ }`;
1546
1736
  return (
1547
1737
  <section className="panel-shell flex min-h-0 flex-1 flex-col p-2">
1548
1738
  <div className="flex items-center justify-between gap-2">
1549
- <SectionLabel>
1550
- Document Viewer · {mission?.missionId ?? "select /goal or /submission"} · {source}
1551
- </SectionLabel>
1739
+ <SectionLabel>Document Viewer · {subtitle}</SectionLabel>
1552
1740
  <button
1553
1741
  type="button"
1554
1742
  onClick={onToggle}
@@ -1597,10 +1785,9 @@ function Arrow() {
1597
1785
  }
1598
1786
 
1599
1787
  function heatColor(count: number) {
1600
- if (count > 6) return "bg-rose-500/90";
1601
- if (count > 4) return "bg-orange-500/80";
1602
- if (count > 2) return "bg-emerald-500/75";
1603
- if (count > 0) return "bg-emerald-500/35";
1788
+ // 3-state activity from sample.count: 0 idle / 1 standby / 2 in-progress.
1789
+ if (count >= 2) return "bg-emerald-500/80";
1790
+ if (count >= 1) return "bg-emerald-500/30";
1604
1791
  return "bg-slate-900";
1605
1792
  }
1606
1793
 
@@ -1673,7 +1860,10 @@ function buildHeatmap(
1673
1860
  const key = `${x}:${sample.laneId}`;
1674
1861
  const prev = aggregated.get(key);
1675
1862
  if (prev) {
1676
- prev.count += sample.count;
1863
+ // Keep the strongest observed state in this bucket (0 < 1 < 2),
1864
+ // never sum — the cell should reflect "what was happening" not "how
1865
+ // many polling ticks happened".
1866
+ prev.count = Math.max(prev.count, sample.count);
1677
1867
  prev.hotfix = prev.hotfix || sample.hotfix;
1678
1868
  if (sample.missionId) prev.missionId = sample.missionId;
1679
1869
  } else {
@@ -248,6 +248,45 @@ describe("readHarnessState", () => {
248
248
  expect(missions.find((mission) => mission.missionId === "goal-1-auth-system")?.type).toBe("goal");
249
249
  });
250
250
 
251
+ it("reads explicit mission lifecycle state and marks the active child mission", () => {
252
+ const harnessDir = path.join(dir, ".harness");
253
+ const goalDir = path.join(harnessDir, "documents", "goal-1-auth-system");
254
+ const submission1Dir = path.join(goalDir, "submission-1-add-sso");
255
+ const submission2Dir = path.join(goalDir, "submission-2-visualization");
256
+ mkdirSync(submission1Dir, { recursive: true });
257
+ mkdirSync(submission2Dir, { recursive: true });
258
+ writeFileSync(path.join(goalDir, "ceo.md"), "# Auth system\n");
259
+ writeFileSync(path.join(submission1Dir, "ceo.md"), "# Add SSO\n");
260
+ writeFileSync(path.join(submission2Dir, "ceo.md"), "# Visualization\n");
261
+ writeFileSync(
262
+ path.join(submission1Dir, "mission-state.json"),
263
+ JSON.stringify({ lifecycle: "closed", active: false })
264
+ );
265
+ writeFileSync(
266
+ path.join(submission2Dir, "mission-state.json"),
267
+ JSON.stringify({ lifecycle: "active", active: true })
268
+ );
269
+
270
+ const missions = readHarnessState(dir).missions;
271
+ const s1 = missions.find((mission) => mission.missionId.endsWith("submission-1-add-sso"))!;
272
+ const s2 = missions.find((mission) => mission.missionId.endsWith("submission-2-visualization"))!;
273
+ expect(s1.lifecycle).toBe("closed");
274
+ expect(s1.active).toBe(false);
275
+ expect(s2.lifecycle).toBe("active");
276
+ expect(s2.active).toBe(true);
277
+ });
278
+
279
+ it("flags CXX reports that lack hired worker evidence", () => {
280
+ const harnessDir = path.join(dir, ".harness");
281
+ const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
282
+ mkdirSync(missionDir, { recursive: true });
283
+ writeFileSync(path.join(missionDir, "ceo.md"), "# Dashboard\n");
284
+ writeFileSync(path.join(missionDir, "cto.md"), "# CTO\n\nImplemented directly.\n");
285
+
286
+ const mission = readHarnessState(dir).missions[0];
287
+ expect(mission.protocolViolations).toContain("cto:missing-worker-evidence");
288
+ });
289
+
251
290
  it("positions only hired HR-Resource workers and marks active workers", () => {
252
291
  const harnessDir = path.join(dir, ".harness");
253
292
  const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
@@ -297,4 +336,49 @@ describe("readHarnessState", () => {
297
336
  sourcePath: ".harness/shared/HR-Resource/react-ui-worker/SKILL.md",
298
337
  });
299
338
  });
339
+
340
+ it("does not revive recently touched workers after runtime completion", () => {
341
+ const harnessDir = path.join(dir, ".harness");
342
+ const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
343
+ mkdirSync(path.join(missionDir, "cto", "workers"), { recursive: true });
344
+ mkdirSync(path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker"), { recursive: true });
345
+ writeFileSync(path.join(missionDir, "ceo.md"), "# Dashboard\n");
346
+ writeFileSync(
347
+ path.join(missionDir, "cto", "workers", "react-ui-worker.md"),
348
+ "## Status\nIN_PROGRESS\n"
349
+ );
350
+ writeFileSync(
351
+ path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker", "SKILL.md"),
352
+ "# React UI Worker\n"
353
+ );
354
+ writeFileSync(
355
+ path.join(harnessDir, "shared", "hr-roster.json"),
356
+ JSON.stringify({
357
+ hired: [
358
+ {
359
+ worker: "react-ui-worker",
360
+ owner: "cto",
361
+ skillPath: ".harness/shared/HR-Resource/react-ui-worker/SKILL.md",
362
+ },
363
+ ],
364
+ })
365
+ );
366
+ writeFileSync(
367
+ path.join(harnessDir, "progress.json"),
368
+ JSON.stringify({
369
+ current_agent: null,
370
+ next_agent: "none",
371
+ agent_status: "completed",
372
+ company_state: {
373
+ workers: [{ agent: "react-ui-worker", status: "running", feature: "F1" }],
374
+ },
375
+ })
376
+ );
377
+
378
+ const workers = readHarnessState(dir).missions[0].workers;
379
+ expect(workers[0]).toMatchObject({
380
+ name: "react-ui-worker",
381
+ active: false,
382
+ });
383
+ });
300
384
  });