@walwal-harness/cli 7.1.27 → 7.1.29

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.
@@ -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
  });
@@ -25,6 +25,7 @@ import type {
25
25
  MeetingRecord,
26
26
  MeetingsState,
27
27
  GotchaEntry,
28
+ ConventionEntry,
28
29
  MissionDoc,
29
30
  OperationsDashboard,
30
31
  OpsServiceHealth,
@@ -226,6 +227,7 @@ function emptySnapshot(banner: ErrorBanner | null = null, rootDir?: string): Har
226
227
  missions: [],
227
228
  ownerHistory: [],
228
229
  gotchas: [],
230
+ conventions: [],
229
231
  todos: [],
230
232
  events: [],
231
233
  };
@@ -791,6 +793,44 @@ interface HiredWorkerEntry {
791
793
  capability: string | null;
792
794
  }
793
795
 
796
+ type MissionLifecycle = MissionDoc["lifecycle"];
797
+
798
+ function normalizeMissionLifecycle(raw: unknown): MissionLifecycle {
799
+ const value = String(raw ?? "").toLowerCase().replace(/[_\s-]+/g, "-");
800
+ if (
801
+ value === "active" ||
802
+ value === "closed" ||
803
+ value === "cancelled" ||
804
+ value === "superseded" ||
805
+ value === "complete" ||
806
+ value === "blocked"
807
+ ) {
808
+ return value;
809
+ }
810
+ if (value === "done" || value === "completed") return "complete";
811
+ if (value === "canceled") return "cancelled";
812
+ return "unknown";
813
+ }
814
+
815
+ function readMissionState(missionPath: string): {
816
+ lifecycle: MissionLifecycle;
817
+ active: boolean | null;
818
+ } {
819
+ const statePath = path.join(missionPath, "mission-state.json");
820
+ const state = readJsonSafe<{
821
+ lifecycle?: unknown;
822
+ status?: unknown;
823
+ active?: boolean;
824
+ }>(statePath);
825
+ if (!state.ok) return { lifecycle: "unknown", active: null };
826
+
827
+ const lifecycle = normalizeMissionLifecycle(state.value.lifecycle ?? state.value.status);
828
+ return {
829
+ lifecycle,
830
+ active: typeof state.value.active === "boolean" ? state.value.active : null,
831
+ };
832
+ }
833
+
794
834
  function deriveWorkerProgress(worker: RawWorker): number | null {
795
835
  const status = (worker?.status ?? worker?.spawn_status ?? "").toLowerCase();
796
836
  if (status === "blocked" || status === "failed") return 0.35;
@@ -938,8 +978,20 @@ function workerDisplayName(content: string, hired: HiredWorkerEntry | undefined,
938
978
  return fallback;
939
979
  }
940
980
 
981
+ function isHarnessRuntimeIdle(progress: RawProgress | null): boolean {
982
+ const status = (progress?.agent_status ?? "").toLowerCase();
983
+ const current = progress?.current_agent ?? null;
984
+ const next = progress?.next_agent ?? null;
985
+ return (
986
+ status === "completed" ||
987
+ status === "complete" ||
988
+ (status === "idle" && current === null && (next === null || String(next) === "none"))
989
+ );
990
+ }
991
+
941
992
  function activeWorkerNames(progress: RawProgress | null): Set<string> {
942
993
  const active = new Set<string>();
994
+ if (isHarnessRuntimeIdle(progress)) return active;
943
995
  const workers = [
944
996
  ...(progress?.company_state?.workers ?? []),
945
997
  ...(progress?.company_state?.last_dispatch ?? []),
@@ -1109,6 +1161,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1109
1161
  if (!existsSync(docsDir)) return [];
1110
1162
  const hiredWorkers = readHiredWorkers(rootDir);
1111
1163
  const activeWorkers = activeWorkerNames(progress);
1164
+ const runtimeIdle = isHarnessRuntimeIdle(progress);
1112
1165
 
1113
1166
  const missionDirs: Array<{ rel: string; abs: string }> = [];
1114
1167
  const cxxDocNames = new Set(["ceo.md", "cto.md", "cqo.md", "coo.md", "cdo.md", "ops.md"]);
@@ -1143,7 +1196,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1143
1196
  return "unknown";
1144
1197
  };
1145
1198
 
1146
- const missions = missionDirs
1199
+ let missions = missionDirs
1147
1200
  .map(({ rel, abs: missionPath }) => {
1148
1201
  let mtime: Date;
1149
1202
  try { mtime = statSync(missionPath).mtime; } catch { mtime = new Date(0); }
@@ -1165,7 +1218,15 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1165
1218
  const reportAbs = path.join(missionPath, relDir, f);
1166
1219
  const content = readMd(`${relDir}/${f}`) ?? "";
1167
1220
  const statusMatch = content.match(/##\s*Status\s*\n+([A-Z_]+)/);
1168
- const status = (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown";
1221
+ // Worker docs sometimes omit the "## Status" block but always carry a
1222
+ // docmeta frontmatter when the worker submitted its output. Treat the
1223
+ // presence of a docmeta block (or a non-empty body) as COMPLETE.
1224
+ const hasDocmeta = /^---[\s\S]*?docmeta:[\s\S]*?---/.test(content);
1225
+ const status: WorkerDocEntry["status"] = statusMatch
1226
+ ? (statusMatch[1] as WorkerDocEntry["status"])
1227
+ : hasDocmeta
1228
+ ? "COMPLETE"
1229
+ : "unknown";
1169
1230
  const name = normalizeWorkerName(f);
1170
1231
  const hired = hiredForMission(hiredWorkers, name, owner, rel);
1171
1232
  let updatedAt: string | null = null;
@@ -1182,7 +1243,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1182
1243
  status,
1183
1244
  owner: hired?.owner ?? owner,
1184
1245
  hired: !!hired,
1185
- active: activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE"),
1246
+ active: !runtimeIdle && (activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE")),
1186
1247
  sourcePath: hired?.sourcePath ?? null,
1187
1248
  reportPath: path.relative(rootDir, reportAbs),
1188
1249
  updatedAt,
@@ -1206,10 +1267,10 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1206
1267
  name: hired.name,
1207
1268
  displayName: workerDisplayName("", hired, hired.name),
1208
1269
  content: "",
1209
- status: activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
1270
+ status: !runtimeIdle && activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
1210
1271
  owner: hired.owner,
1211
1272
  hired: true,
1212
- active: activeWorkers.has(hired.name),
1273
+ active: !runtimeIdle && activeWorkers.has(hired.name),
1213
1274
  sourcePath: hired.sourcePath,
1214
1275
  reportPath: null,
1215
1276
  updatedAt: null,
@@ -1220,6 +1281,23 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1220
1281
 
1221
1282
  const cxxRoles = ["ceo", "cto", "cqo", "coo", "cdo", "ops"] as const;
1222
1283
  const cxxPresent = cxxRoles.filter(role => existsSync(path.join(missionPath, `${role}.md`)));
1284
+ const state = readMissionState(missionPath);
1285
+ const protocolViolations: string[] = [];
1286
+ for (const role of cxxPresent) {
1287
+ if (role === "ceo") continue;
1288
+ const roleWorkers = workers.filter((w) => w.owner === role);
1289
+ const roleDoc = readMd(`${role}.md`) ?? "";
1290
+ const hasManifest = /Worker Evidence Manifest/i.test(roleDoc);
1291
+ const mentionsWorkerReport = new RegExp(`${role}/workers/`, "i").test(roleDoc);
1292
+ if (roleWorkers.length === 0 || !hasManifest || !mentionsWorkerReport) {
1293
+ protocolViolations.push(`${role}:missing-worker-evidence`);
1294
+ }
1295
+ }
1296
+ for (const w of workers) {
1297
+ if (w.owner === "unknown" || !w.hired) {
1298
+ protocolViolations.push(`${w.owner}:${w.name}:not-hired-or-unowned`);
1299
+ }
1300
+ }
1223
1301
 
1224
1302
  const id = rel;
1225
1303
  const label = rel.split("/").pop() ?? rel;
@@ -1227,6 +1305,9 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1227
1305
  missionId: id,
1228
1306
  ts: mtime.toISOString(),
1229
1307
  type: typeFromId(id),
1308
+ lifecycle: state.lifecycle,
1309
+ active: state.active ?? false,
1310
+ protocolViolations,
1230
1311
  label,
1231
1312
  ceo: readMd("ceo.md"),
1232
1313
  cto: readMd("cto.md"),
@@ -1239,8 +1320,22 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1239
1320
  cxxPresent: [...cxxPresent],
1240
1321
  } satisfies MissionDoc;
1241
1322
  })
1242
- .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
1243
- .slice(0, limit);
1323
+ .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
1324
+
1325
+ const explicitActive = missions.some((m) => m.active);
1326
+ if (!explicitActive) {
1327
+ const latestOpen = missions.find(
1328
+ (m) => m.lifecycle === "active" || m.lifecycle === "unknown"
1329
+ );
1330
+ if (latestOpen) {
1331
+ missions = missions.map((m) => ({
1332
+ ...m,
1333
+ active: m.missionId === latestOpen.missionId,
1334
+ }));
1335
+ }
1336
+ }
1337
+
1338
+ missions = missions.slice(0, limit);
1244
1339
 
1245
1340
  // When the CEO updates a goal directory in-place for a submission or hot-fix
1246
1341
  // (instead of creating a submission-* or hotfix-* subdirectory), the most
@@ -1293,32 +1388,51 @@ function readOwnerHistory(rootDir: string, limit = 30): OwnerPromptEntry[] {
1293
1388
  return entries.reverse().slice(0, limit);
1294
1389
  }
1295
1390
 
1296
- function readGotchas(rootDir: string): GotchaEntry[] {
1297
- const gotchasDir = path.join(rootDir, ".harness", "gotchas");
1298
- if (!existsSync(gotchasDir)) return [];
1391
+ function readKnowledgeDir<T extends GotchaEntry | ConventionEntry>(
1392
+ rootDir: string,
1393
+ relDir: string
1394
+ ): T[] {
1395
+ const dir = path.join(rootDir, ".harness", relDir);
1396
+ if (!existsSync(dir)) return [];
1299
1397
  let entries: Dirent[];
1300
- try { entries = readdirSync(gotchasDir, { withFileTypes: true }); } catch { return []; }
1301
- const results: GotchaEntry[] = [];
1398
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
1399
+ const results: T[] = [];
1302
1400
  for (const entry of entries) {
1303
1401
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
1304
1402
  if (entry.name === "README.md") continue;
1305
1403
  const id = entry.name.replace(/\.md$/, "");
1404
+ const abs = path.join(dir, entry.name);
1306
1405
  let content: string;
1307
- try { content = readFileSync(path.join(gotchasDir, entry.name), "utf8"); } catch { continue; }
1308
- // Extract title from first H1 after optional frontmatter
1406
+ try { content = readFileSync(abs, "utf8"); } catch { continue; }
1309
1407
  const body = content.replace(/^---[\s\S]*?---\n+/, "");
1310
1408
  const h1 = body.match(/^#\s+(.+)/m);
1311
1409
  const title = h1 ? h1[1].trim() : id;
1312
- // Extract tags from docmeta frontmatter
1313
1410
  const tagsMatch = content.match(/^\s*tags:\s*\[([^\]]*)\]/m);
1314
1411
  const tags = tagsMatch
1315
1412
  ? tagsMatch[1].split(",").map((t) => t.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean)
1316
1413
  : [];
1317
- results.push({ id, title, content, tags });
1414
+ let updatedAt: string | null = null;
1415
+ try { updatedAt = statSync(abs).mtime.toISOString(); } catch { /* ignore */ }
1416
+ results.push({
1417
+ id,
1418
+ title,
1419
+ content,
1420
+ tags,
1421
+ sourcePath: path.relative(rootDir, abs),
1422
+ updatedAt,
1423
+ } as T);
1318
1424
  }
1319
1425
  return results.sort((a, b) => a.id.localeCompare(b.id));
1320
1426
  }
1321
1427
 
1428
+ function readGotchas(rootDir: string): GotchaEntry[] {
1429
+ return readKnowledgeDir<GotchaEntry>(rootDir, "gotchas");
1430
+ }
1431
+
1432
+ function readConventions(rootDir: string): ConventionEntry[] {
1433
+ return readKnowledgeDir<ConventionEntry>(rootDir, "conventions");
1434
+ }
1435
+
1322
1436
  function normalizeTodoStatus(raw: unknown): CxxTodo["status"] {
1323
1437
  const value = String(raw ?? "pending").toLowerCase();
1324
1438
  if (["pending", "active", "paused", "blocked", "done"].includes(value)) return value;
@@ -1431,6 +1545,7 @@ export function readHarnessState(rootDir: string): HarnessSnapshot {
1431
1545
  snapshot.missions = readMissions(rootDir, null);
1432
1546
  snapshot.ownerHistory = readOwnerHistory(rootDir);
1433
1547
  snapshot.gotchas = readGotchas(rootDir);
1548
+ snapshot.conventions = readConventions(rootDir);
1434
1549
  snapshot.todos = readTodos(rootDir);
1435
1550
  snapshot.events = readEvents(rootDir);
1436
1551
  return snapshot;
@@ -1485,6 +1600,7 @@ export function readHarnessState(rootDir: string): HarnessSnapshot {
1485
1600
  missions: readMissions(rootDir, progress),
1486
1601
  ownerHistory: readOwnerHistory(rootDir),
1487
1602
  gotchas: readGotchas(rootDir),
1603
+ conventions: readConventions(rootDir),
1488
1604
  todos: readTodos(rootDir),
1489
1605
  events: readEvents(rootDir),
1490
1606
  };
@@ -292,6 +292,7 @@ export interface HarnessSnapshot {
292
292
  missions: MissionDoc[];
293
293
  ownerHistory: OwnerPromptEntry[];
294
294
  gotchas: GotchaEntry[];
295
+ conventions: ConventionEntry[];
295
296
  todos: CxxTodo[];
296
297
  events: HarnessEvent[];
297
298
  }
@@ -339,6 +340,9 @@ export interface MissionDoc {
339
340
  missionId: string;
340
341
  ts: string;
341
342
  type: "goal" | "submission" | "hotfix" | "feature" | "unknown";
343
+ lifecycle: "active" | "closed" | "cancelled" | "superseded" | "complete" | "blocked" | "unknown";
344
+ active: boolean;
345
+ protocolViolations: string[];
342
346
  label: string;
343
347
  ceo: string | null;
344
348
  cto: string | null;
@@ -362,6 +366,17 @@ export interface GotchaEntry {
362
366
  title: string;
363
367
  content: string;
364
368
  tags: string[];
369
+ sourcePath?: string | null;
370
+ updatedAt?: string | null;
371
+ }
372
+
373
+ export interface ConventionEntry {
374
+ id: string;
375
+ title: string;
376
+ content: string;
377
+ tags: string[];
378
+ sourcePath?: string | null;
379
+ updatedAt?: string | null;
365
380
  }
366
381
 
367
382
  export type OrgRole = "owner" | "ceo" | "coo" | "cdo" | "cto" | "cqo" | "ops";