@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 +2 -0
- package/apps/harness-dashboard/components/Scene.tsx +19 -12
- package/apps/harness-dashboard/lib/harness-state.ts +35 -0
- package/apps/harness-dashboard/lib/types.ts +9 -0
- package/bin/init.js +15 -0
- package/package.json +1 -1
- package/scripts/conductor-tick.sh +4 -0
- package/scripts/harness-activity-record.js +207 -0
- package/scripts/harness-company-complete.sh +4 -0
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
|
|
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
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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 (
|
|
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,
|
|
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.
|
|
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"
|