@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 +25 -1
- package/apps/harness-dashboard/app/api/stream/route.ts +4 -0
- package/apps/harness-dashboard/components/Scene.tsx +233 -43
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +84 -0
- package/apps/harness-dashboard/lib/harness-state.ts +91 -6
- package/apps/harness-dashboard/lib/types.ts +3 -0
- package/assets/templates/AGENTS.md.template +6 -5
- package/bin/init.js +273 -41
- package/commands/goal.md +10 -8
- package/commands/hot-fix.md +12 -10
- package/commands/submission.md +12 -9
- package/package.json +1 -1
- package/scripts/harness-company-complete.sh +30 -0
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`
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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,
|
|
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={
|
|
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={
|
|
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
|
-
<
|
|
783
|
+
<button
|
|
710
784
|
key={entry.id}
|
|
711
|
-
|
|
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
|
-
</
|
|
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-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
{
|
|
919
|
+
{visibleTotals.total}
|
|
819
920
|
</td>
|
|
820
921
|
<td className="px-2 py-1 text-right font-semibold text-emerald-300">
|
|
821
|
-
{
|
|
922
|
+
{visibleTotals.done}
|
|
822
923
|
</td>
|
|
823
924
|
<td className="px-2 py-1 text-right font-semibold text-orange-300">
|
|
824
|
-
{
|
|
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
|
-
): {
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1542
|
-
? `${
|
|
1543
|
-
:
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
1601
|
-
if (count
|
|
1602
|
-
if (count
|
|
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
|
-
|
|
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
|
});
|