@walwal-harness/cli 7.1.30 → 7.1.32

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
@@ -201,6 +201,7 @@ It also closes structured work state:
201
201
  - active/pending/paused todos in `.harness/todos/state.json` become `done`
202
202
  - active `mission-state.json` files become `{"lifecycle":"complete","active":false}`
203
203
  - the dashboard stops treating recent worker report mtimes as live activity after runtime completion
204
+ - heatmap activity is persisted in `.harness/activity/YYYY-MM-DD.jsonl` and pruned after 7 days
204
205
 
205
206
  Existing users should update and migrate from the target project:
206
207
 
@@ -282,6 +283,7 @@ harness-hiring → register and onboard worker
282
283
 
283
284
  | Version | Summary |
284
285
  |---|---|
286
+ | 7.1.31 | Persist dashboard heatmap activity to 7-day JSONL logs, load persisted samples in the dashboard, and backfill activity during migrate from existing mission docs/progress logs |
285
287
  | 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 |
286
288
  | 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 |
287
289
  | 7.1.19 | `migrate` now carries OPS verification rules into existing installs: runtime.verification merge, HARNESS refresh, and AGENTS migration block |
@@ -60,7 +60,7 @@ type CxxRole = typeof CXX_ROLES[number];
60
60
  const WORKER_OWNERS = ["coo", "cdo", "cto", "cqo", "ops"] as const;
61
61
  type WorkerOwnerRole = typeof WORKER_OWNERS[number];
62
62
  const BUCKET_MS = 60_000;
63
- const VISIBLE_BUCKETS = 60;
63
+ const VISIBLE_BUCKETS = 24 * 60;
64
64
  const LANE_LABEL_WIDTH = 110;
65
65
  const TIME_LABEL_HEIGHT = 18;
66
66
  const LANE_ROW_HEIGHT = 18;
@@ -117,7 +117,18 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
117
117
  }, [snapshot.missions, selectedGoal]);
118
118
 
119
119
  const lanes = useMemo(() => buildLanes(snapshot, selectedMission), [selectedMission, snapshot]);
120
- const sessionStartedAtRef = useRef(Date.now());
120
+ const heatmapStartedAt = useMemo(() => Date.now() - VISIBLE_BUCKETS * BUCKET_MS, [snapshot.ts]);
121
+ const persistedHeatSamples = useMemo<HeatSample[]>(
122
+ () =>
123
+ snapshot.activitySamples.map((sample) => ({
124
+ ts: Date.parse(sample.ts),
125
+ laneId: sample.laneId,
126
+ count: sample.count,
127
+ hotfix: sample.hotfix,
128
+ missionId: sample.missionId,
129
+ })).filter((sample) => Number.isFinite(sample.ts)),
130
+ [snapshot.activitySamples]
131
+ );
121
132
  const [heatSamples, setHeatSamples] = useState<HeatSample[]>([]);
122
133
  const sampleSourceRef = useRef({ lanes, snapshot, selectedMission });
123
134
  useEffect(() => {
@@ -148,20 +159,16 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
148
159
  const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
149
160
  const next: HeatSample[] = [];
150
161
  for (const lane of lanes) {
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)
162
+ // Live overlay records active work only. Historical participation is
163
+ // backfilled from persisted activity logs; completed reports must not
164
+ // keep repainting the current minute forever.
155
165
  let activity = 0;
156
166
  if (lane.kind === "worker") {
157
167
  const w = lane.worker;
158
168
  if (w) {
159
169
  if (w.active || w.status === "IN_PROGRESS") activity = 2;
160
- else if (w.content || w.status === "COMPLETE") activity = 1;
161
170
  }
162
171
  } else {
163
- const presentInMission =
164
- lane.mission?.cxxPresent.includes(lane.role) ?? false;
165
172
  const liveTodos = snapshot.todos.filter(
166
173
  (t) =>
167
174
  t.owner === lane.role &&
@@ -172,7 +179,7 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
172
179
  (w) => w.active || w.status === "IN_PROGRESS"
173
180
  ).length;
174
181
  if (liveTodos > 0 && runningWorkers === 0) activity = 2;
175
- else if (presentInMission || lane.workers.length > 0 || runningWorkers > 0) activity = 1;
182
+ else if (runningWorkers > 0) activity = 2;
176
183
  }
177
184
  if (activity <= 0) continue;
178
185
  next.push({
@@ -194,8 +201,8 @@ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
194
201
  return () => clearInterval(id);
195
202
  }, []);
196
203
  const { cells: heatmap, totalBuckets } = useMemo(
197
- () => buildHeatmap(snapshot, lanes, heatSamples, sessionStartedAtRef.current),
198
- [heatSamples, lanes, snapshot]
204
+ () => buildHeatmap(snapshot, lanes, [...persistedHeatSamples, ...heatSamples], heatmapStartedAt),
205
+ [heatSamples, heatmapStartedAt, lanes, persistedHeatSamples, snapshot]
199
206
  );
200
207
  const [selectedLaneId, setSelectedLaneId] = useState(lanes[0]?.id ?? "ceo");
201
208
  const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
@@ -40,11 +40,13 @@ import type {
40
40
  WorkerDocEntry,
41
41
  WorkerSnapshot,
42
42
  CxxTodo,
43
+ ActivitySample,
43
44
  } from "./types";
44
45
 
45
46
  const SNAPSHOT_VERSION = "1.2.0";
46
47
  const GOAL_DESC_TRUNCATE = 200;
47
48
  const RECENT_WORKER_ACTIVE_MS = 10 * 60 * 1000;
49
+ const ACTIVITY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
48
50
 
49
51
  interface RawProgress {
50
52
  goals?: {
@@ -230,6 +232,7 @@ function emptySnapshot(banner: ErrorBanner | null = null, rootDir?: string): Har
230
232
  conventions: [],
231
233
  todos: [],
232
234
  events: [],
235
+ activitySamples: [],
233
236
  };
234
237
  }
235
238
 
@@ -1516,6 +1519,36 @@ function readEvents(rootDir: string, limit = 40): HarnessEvent[] {
1516
1519
  }));
1517
1520
  }
1518
1521
 
1522
+ function readActivitySamples(rootDir: string): ActivitySample[] {
1523
+ const activityDir = path.join(rootDir, ".harness", "activity");
1524
+ if (!existsSync(activityDir)) return [];
1525
+ const cutoff = Date.now() - ACTIVITY_RETENTION_MS;
1526
+ const files = readdirSync(activityDir)
1527
+ .filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))
1528
+ .sort()
1529
+ .slice(-8);
1530
+ const rows: ActivitySample[] = [];
1531
+ for (const file of files) {
1532
+ for (const raw of readJsonlTail<Record<string, unknown>>(path.join(activityDir, file), 100_000).reverse()) {
1533
+ const ts = typeof raw.ts === "string" ? raw.ts : null;
1534
+ const time = ts ? Date.parse(ts) : NaN;
1535
+ if (!ts || !Number.isFinite(time) || time < cutoff) continue;
1536
+ const laneId = typeof raw.laneId === "string" ? raw.laneId : null;
1537
+ if (!laneId) continue;
1538
+ const count = Math.max(0, Math.min(2, Number(raw.count ?? 0)));
1539
+ if (count <= 0) continue;
1540
+ rows.push({
1541
+ ts,
1542
+ laneId,
1543
+ count,
1544
+ hotfix: raw.hotfix === true,
1545
+ missionId: typeof raw.missionId === "string" ? raw.missionId : null,
1546
+ });
1547
+ }
1548
+ }
1549
+ return rows.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
1550
+ }
1551
+
1519
1552
  export function readHarnessState(rootDir: string): HarnessSnapshot {
1520
1553
  const harnessDir = path.join(rootDir, ".harness");
1521
1554
  if (!existsSync(harnessDir)) {
@@ -1548,6 +1581,7 @@ export function readHarnessState(rootDir: string): HarnessSnapshot {
1548
1581
  snapshot.conventions = readConventions(rootDir);
1549
1582
  snapshot.todos = readTodos(rootDir);
1550
1583
  snapshot.events = readEvents(rootDir);
1584
+ snapshot.activitySamples = readActivitySamples(rootDir);
1551
1585
  return snapshot;
1552
1586
  }
1553
1587
 
@@ -1603,6 +1637,7 @@ export function readHarnessState(rootDir: string): HarnessSnapshot {
1603
1637
  conventions: readConventions(rootDir),
1604
1638
  todos: readTodos(rootDir),
1605
1639
  events: readEvents(rootDir),
1640
+ activitySamples: readActivitySamples(rootDir),
1606
1641
  };
1607
1642
  }
1608
1643
 
@@ -295,6 +295,15 @@ export interface HarnessSnapshot {
295
295
  conventions: ConventionEntry[];
296
296
  todos: CxxTodo[];
297
297
  events: HarnessEvent[];
298
+ activitySamples: ActivitySample[];
299
+ }
300
+
301
+ export interface ActivitySample {
302
+ ts: string;
303
+ laneId: string;
304
+ count: number;
305
+ hotfix: boolean;
306
+ missionId: string | null;
298
307
  }
299
308
 
300
309
  export interface CxxTodo {
package/bin/init.js CHANGED
@@ -941,6 +941,20 @@ function refreshScriptsForMigrate({ dryRun = false, backupDir = null } = {}) {
941
941
  chmodShellScripts(scriptsDest);
942
942
  }
943
943
 
944
+ function migrateActivityHistory({ dryRun = false } = {}) {
945
+ const recorder = path.join(PROJECT_ROOT, 'scripts', 'harness-activity-record.js');
946
+ if (!fs.existsSync(recorder)) return;
947
+ log(' activity/: legacy documents/progress.log → 7-day heatmap JSONL backfill');
948
+ if (dryRun) return;
949
+ try {
950
+ execSync(`node ${JSON.stringify(recorder)} ${JSON.stringify(PROJECT_ROOT)} --migrate`, {
951
+ stdio: 'ignore',
952
+ });
953
+ } catch {
954
+ log(' activity/: backfill skipped (node recorder failed)');
955
+ }
956
+ }
957
+
944
958
  // ─────────────────────────────────────────
945
959
  // 3a. Commands → .claude/commands/
946
960
  // ─────────────────────────────────────────
@@ -1996,6 +2010,7 @@ function runMigrate(opts = {}) {
1996
2010
  // Runtime scripts are package-owned. Migrate must refresh them so existing
1997
2011
  // projects receive wake/meeting/OPS fixes without requiring a full init.
1998
2012
  refreshScriptsForMigrate({ dryRun, backupDir });
2013
+ migrateActivityHistory({ dryRun });
1999
2014
  updateRuleRegistryLinks(flags.ruleRegistryLinks || [], { dryRun, backupDir });
2000
2015
 
2001
2016
  // 1. progress.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.30",
3
+ "version": "7.1.32",
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"
@@ -656,6 +656,10 @@ fi
656
656
 
657
657
  set_progress "$filter"
658
658
 
659
+ if command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/harness-activity-record.js" ]; then
660
+ node "$SCRIPT_DIR/harness-activity-record.js" "$PROJECT_ROOT" >/dev/null 2>&1 || true
661
+ fi
662
+
659
663
  if [ "$meeting_prepare" = true ] && [ "$next" = "meeting-manager" ]; then
660
664
  bash "$SCRIPT_DIR/harness-meeting-doc.sh" "$PROJECT_ROOT" prepare >/dev/null 2>&1 || true
661
665
  fi
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+ const RETENTION_MS = 7 * DAY_MS;
7
+ const RECENT_MS = 10 * 60 * 1000;
8
+ const CXX_ROLES = ["ceo", "coo", "cdo", "cto", "cqo", "ops"];
9
+ const WORKER_OWNERS = ["coo", "cdo", "cto", "cqo", "ops"];
10
+ const TERMINAL_LIFECYCLES = new Set(["closed", "cancelled", "superseded", "complete"]);
11
+
12
+ function readJson(filePath, fallback = null) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
19
+
20
+ function readText(filePath) {
21
+ try {
22
+ return fs.readFileSync(filePath, "utf8");
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function walk(dir) {
29
+ const out = [];
30
+ if (!fs.existsSync(dir)) return out;
31
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
32
+ const p = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) out.push(...walk(p));
34
+ else out.push(p);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function isoDay(ts) {
40
+ return new Date(ts).toISOString().slice(0, 10);
41
+ }
42
+
43
+ function ensureDir(dir) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+
47
+ function pruneActivity(activityDir, now = Date.now()) {
48
+ if (!fs.existsSync(activityDir)) return;
49
+ for (const entry of fs.readdirSync(activityDir)) {
50
+ if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(entry)) continue;
51
+ const day = entry.slice(0, 10);
52
+ const cutoff = Date.parse(`${day}T23:59:59.999Z`);
53
+ if (Number.isFinite(cutoff) && now - cutoff > RETENTION_MS) {
54
+ fs.rmSync(path.join(activityDir, entry), { force: true });
55
+ }
56
+ }
57
+ }
58
+
59
+ function parseLifecycle(missionDir) {
60
+ const state = readJson(path.join(missionDir, "mission-state.json"), null);
61
+ const lifecycle = typeof state?.lifecycle === "string" ? state.lifecycle : "unknown";
62
+ return { lifecycle, active: state?.active === true || lifecycle === "active" };
63
+ }
64
+
65
+ function collectMissions(root) {
66
+ const docsDir = path.join(root, ".harness", "documents");
67
+ if (!fs.existsSync(docsDir)) return [];
68
+ const dirs = [];
69
+ const visit = (dir) => {
70
+ if (fs.existsSync(path.join(dir, "ceo.md")) || fs.existsSync(path.join(dir, "mission-state.json"))) {
71
+ dirs.push(dir);
72
+ }
73
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
74
+ if (entry.isDirectory() && !["workers", "archive"].includes(entry.name)) visit(path.join(dir, entry.name));
75
+ }
76
+ };
77
+ visit(docsDir);
78
+ return dirs.map((dir) => {
79
+ const rel = path.relative(docsDir, dir);
80
+ const state = parseLifecycle(dir);
81
+ const cxxPresent = CXX_ROLES.filter((role) => fs.existsSync(path.join(dir, `${role}.md`)));
82
+ const workers = [];
83
+ for (const owner of WORKER_OWNERS) {
84
+ const workerDir = path.join(dir, owner, "workers");
85
+ for (const file of walk(workerDir).filter((p) => p.endsWith(".md"))) {
86
+ const content = readText(file);
87
+ const stat = fs.statSync(file);
88
+ const name = path.basename(file, ".md");
89
+ const complete = /(^|\n)(status:\s*COMPLETE|##\s*Status\s*\n\s*COMPLETE)\b/i.test(content);
90
+ workers.push({
91
+ owner,
92
+ name,
93
+ laneId: `${owner}:${name}`,
94
+ content,
95
+ complete,
96
+ updatedAt: stat.mtimeMs,
97
+ });
98
+ }
99
+ }
100
+ return {
101
+ missionId: rel,
102
+ dir,
103
+ lifecycle: state.lifecycle,
104
+ active: state.active,
105
+ cxxPresent,
106
+ workers,
107
+ mtimeMs: fs.statSync(dir).mtimeMs,
108
+ };
109
+ });
110
+ }
111
+
112
+ function readTodos(root) {
113
+ const state = readJson(path.join(root, ".harness", "todos", "state.json"), {});
114
+ const owners = state?.owners && typeof state.owners === "object" ? state.owners : {};
115
+ return Object.entries(owners).flatMap(([owner, items]) =>
116
+ Array.isArray(items)
117
+ ? items.map((item) => ({ ...item, owner }))
118
+ : []
119
+ );
120
+ }
121
+
122
+ function runtimeIdle(root) {
123
+ const p = readJson(path.join(root, ".harness", "progress.json"), {});
124
+ return (
125
+ p?.agent_status === "completed" ||
126
+ p?.agent_status === "complete" ||
127
+ ((p?.current_agent ?? null) === null && (p?.next_agent === null || p?.next_agent === "none"))
128
+ );
129
+ }
130
+
131
+ function currentSamples(root, now = Date.now()) {
132
+ const missions = collectMissions(root);
133
+ const active = missions.find((m) => m.active) ?? missions.sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null;
134
+ if (!active) return [];
135
+ const todos = readTodos(root);
136
+ const idle = runtimeIdle(root);
137
+ const samples = [];
138
+ for (const role of CXX_ROLES) {
139
+ const workers = active.workers.filter((w) => w.owner === role);
140
+ const runningWorkers = workers.filter((w) => !idle && !w.complete && now - w.updatedAt < RECENT_MS);
141
+ const liveTodos = todos.filter((t) => t.owner === role && !["done", "completed"].includes(String(t.status)));
142
+ let count = 0;
143
+ if (!idle && liveTodos.length > 0 && runningWorkers.length === 0) count = 2;
144
+ else if (runningWorkers.length > 0) count = 2;
145
+ if (count > 0) samples.push({ ts: new Date(now).toISOString(), laneId: role, count, hotfix: active.missionId.includes("hotfix"), missionId: active.missionId });
146
+ }
147
+ for (const worker of active.workers) {
148
+ const count = !idle && !worker.complete && now - worker.updatedAt < RECENT_MS ? 2 : 0;
149
+ if (count > 0) samples.push({ ts: new Date(now).toISOString(), laneId: worker.laneId, count, hotfix: active.missionId.includes("hotfix"), missionId: active.missionId });
150
+ }
151
+ return samples;
152
+ }
153
+
154
+ function legacySamples(root) {
155
+ const now = Date.now();
156
+ const samples = [];
157
+ for (const mission of collectMissions(root)) {
158
+ const terminal = TERMINAL_LIFECYCLES.has(mission.lifecycle);
159
+ for (const role of mission.cxxPresent.filter((r) => CXX_ROLES.includes(r))) {
160
+ const file = path.join(mission.dir, `${role}.md`);
161
+ const ts = fs.existsSync(file) ? fs.statSync(file).mtimeMs : mission.mtimeMs;
162
+ if (now - ts <= RETENTION_MS) {
163
+ samples.push({ ts: new Date(ts).toISOString(), laneId: role, count: terminal ? 1 : 2, hotfix: mission.missionId.includes("hotfix"), missionId: mission.missionId });
164
+ }
165
+ }
166
+ for (const worker of mission.workers) {
167
+ if (now - worker.updatedAt <= RETENTION_MS) {
168
+ samples.push({ ts: new Date(worker.updatedAt).toISOString(), laneId: worker.laneId, count: worker.complete || terminal ? 1 : 2, hotfix: mission.missionId.includes("hotfix"), missionId: mission.missionId });
169
+ }
170
+ }
171
+ }
172
+ const progressLog = path.join(root, ".harness", "progress.log");
173
+ for (const line of readText(progressLog).split("\n")) {
174
+ const m = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z).*\b(ceo|coo|cdo|cto|cqo|ops)\b/i);
175
+ if (!m) continue;
176
+ const ts = Date.parse(m[1]);
177
+ if (Number.isFinite(ts) && now - ts <= RETENTION_MS) {
178
+ samples.push({ ts: new Date(ts).toISOString(), laneId: m[2].toLowerCase(), count: 1, hotfix: /hot-?fix/i.test(line), missionId: null });
179
+ }
180
+ }
181
+ return samples;
182
+ }
183
+
184
+ function appendSamples(root, samples) {
185
+ if (samples.length === 0) return;
186
+ const activityDir = path.join(root, ".harness", "activity");
187
+ ensureDir(activityDir);
188
+ const byDay = new Map();
189
+ for (const sample of samples) {
190
+ const day = isoDay(Date.parse(sample.ts));
191
+ if (!byDay.has(day)) byDay.set(day, []);
192
+ byDay.get(day).push(sample);
193
+ }
194
+ for (const [day, rows] of byDay) {
195
+ fs.appendFileSync(path.join(activityDir, `${day}.jsonl`), rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
196
+ }
197
+ pruneActivity(activityDir);
198
+ }
199
+
200
+ function main() {
201
+ const root = path.resolve(process.argv[2] || ".");
202
+ const mode = process.argv.includes("--migrate") ? "migrate" : "sample";
203
+ if (!fs.existsSync(path.join(root, ".harness"))) return;
204
+ appendSamples(root, mode === "migrate" ? legacySamples(root) : currentSamples(root));
205
+ }
206
+
207
+ main();
@@ -70,4 +70,8 @@ if [ -d "$DOCS" ]; then
70
70
  done
71
71
  fi
72
72
 
73
+ if command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/harness-activity-record.js" ]; then
74
+ node "$SCRIPT_DIR/harness-activity-record.js" "$PROJECT_ROOT" >/dev/null 2>&1 || true
75
+ fi
76
+
73
77
  echo "[company-complete] marked complete: $REASON"