@walwal-harness/cli 7.1.48 → 7.1.49

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/CHANGELOG.md CHANGED
@@ -47,7 +47,7 @@ docmeta:
47
47
  - Dashboard runtime now lives under each project’s `.harness/dashboard/` instead of the shared `~/.walwal-harness/dashboard/` cache, with generated dependencies/build output ignored via `.gitignore`.
48
48
  - Dashboard heatmap is no longer filtered by the selected goal/submission/hot-fix; it shows all recorded activity while still preserving mission IDs on active cells.
49
49
  - Dashboard worker parsing now accepts `name`, `progress`, `eta`, and `report_path` telemetry and no longer treats docmeta-only report drafts as proof of live worker activity.
50
- - Init/migrate now best-effort registers the project with the hourly wake scheduler, and wake status now uses the macOS `launchctl print gui/$UID/...` state to avoid false `NOT loaded` reports.
50
+ - Hourly wake registration is opt-in during init/migrate (`--enable-wake` or `WALWAL_ENABLE_WAKE=1`), and wake status now uses the macOS `launchctl print gui/$UID/...` state to avoid false `NOT loaded` reports.
51
51
 
52
52
  ## 7.1.36 — Stop hook active-loop guard + visible heatmap (2026-05-29)
53
53
 
@@ -74,4 +74,3 @@ Required sections:
74
74
  8. `## Implementation Notes`
75
75
 
76
76
  Use `None` for empty sections. Include exact evidence paths or tool names for every claim.
77
-
package/README.md CHANGED
@@ -243,6 +243,7 @@ Features:
243
243
 
244
244
  Hourly autonomous wake:
245
245
 
246
+ - Init/migrate does not auto-register launchd wake by default. Use `--enable-wake` or `WALWAL_ENABLE_WAKE=1` when you want installation/migration to register it; otherwise run `bash scripts/harness-wake-install.sh install .` from the target project.
246
247
  - `HARNESS_WAKE_EXECUTOR=claude|codex` selects `claude -p` or `codex exec`.
247
248
  - `HARNESS_WAKE_MODEL=<model>` optionally pins the Claude model for wake ticks.
248
249
  - Each wake tick prompts CEO to convene CXX + OPS, collect progress and decisions, dispatch the next action without asking Owner, and require CQO/OPS verification until the active goal works.
@@ -286,7 +287,7 @@ harness-hiring → register and onboard worker
286
287
  | Version | Summary |
287
288
  |---|---|
288
289
  | 7.1.38 | CEO can approve reversible routine operations without Owner confirmation, including monitoring cadence, local scheduler activation, Telegram briefing formats with existing credentials, dashboard refresh, and consolidation/supersede cleanup |
289
- | 7.1.37 | Project-local dashboard runtime under `.harness/dashboard/`, unfiltered all-mission heatmap, worker telemetry parsing, and more reliable hourly wake registration/status |
290
+ | 7.1.37 | Project-local dashboard runtime under `.harness/dashboard/`, unfiltered all-mission heatmap, worker telemetry parsing, and opt-in hourly wake registration/status |
290
291
  | 7.1.36 | Stop hook only validates worker evidence while chaining the active loop, scopes validation to the newest active mission, and makes the 24h heatmap visible at 10-minute resolution |
291
292
  | 7.1.35 | Fix dashboard heatmap mission scoping, choose the newest active mission for activity recording, and normalize duplicate active child mission states during init/migrate |
292
293
  | 7.1.34 | Fix dashboard 500 when `company_state.workers` is recorded as a keyed worker map instead of an array |
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toContractState } from "../brick/adapter";
3
+ import type { HarnessSnapshot } from "../types";
4
+
5
+ function snapshotWithActivity(ts: string): HarnessSnapshot {
6
+ return {
7
+ version: "test",
8
+ ts: new Date().toISOString(),
9
+ projectName: "demo",
10
+ projectPath: "/tmp/demo",
11
+ errorBanner: null,
12
+ runtime: {
13
+ currentAgent: null,
14
+ agentStatus: "pending",
15
+ nextAgent: null,
16
+ updatedAt: null,
17
+ conductorState: "running",
18
+ ownerPrompt: null,
19
+ },
20
+ incidents: [],
21
+ missions: [],
22
+ ownerHistory: [],
23
+ gotchas: [],
24
+ conventions: [],
25
+ todos: [],
26
+ events: [],
27
+ activitySamples: [{ ts, laneId: "cto", count: 1, hotfix: false, missionId: "goal-1" }],
28
+ files: [],
29
+ };
30
+ }
31
+
32
+ describe("toContractState", () => {
33
+ it("does not mark a role active from stale historical activity samples", () => {
34
+ const now = Date.parse("2026-06-05T00:10:00Z");
35
+ const state = toContractState(snapshotWithActivity("2026-06-05T00:00:00Z"), true, now);
36
+ expect(state.agents.find((agent) => agent.id === "cto")?.status).toBe("idle");
37
+ });
38
+
39
+ it("marks a role active from recent activity samples", () => {
40
+ const now = Date.parse("2026-06-05T00:01:00Z");
41
+ const state = toContractState(snapshotWithActivity("2026-06-05T00:00:30Z"), true, now);
42
+ expect(state.agents.find((agent) => agent.id === "cto")?.status).toBe("active");
43
+ });
44
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -267,4 +267,36 @@ describeHarnessShell("company-loop shell flows", () => {
267
267
  expect(progress.planner.requested_mode).toBeNull();
268
268
  expect(progress.meetings.requested_type).toBe("followup-review");
269
269
  });
270
+
271
+ it("company terminal transitions only update the newest current mission state", () => {
272
+ writeState(dir, {
273
+ current_agent: "ceo",
274
+ agent_status: "running",
275
+ next_agent: "none",
276
+ owner_prompt: { status: "routing" },
277
+ company_state: { state: "running", active_workers: 0 },
278
+ conductor: { state: "running", tracks: [] },
279
+ });
280
+ const docs = path.join(dir, ".harness", "documents");
281
+ const oldMission = path.join(docs, "goal-1-old");
282
+ const newMission = path.join(docs, "goal-2-new");
283
+ mkdirSync(oldMission, { recursive: true });
284
+ mkdirSync(newMission, { recursive: true });
285
+ const oldState = path.join(oldMission, "mission-state.json");
286
+ const newState = path.join(newMission, "mission-state.json");
287
+ writeFileSync(oldState, JSON.stringify({ lifecycle: "active", active: true }));
288
+ writeFileSync(newState, JSON.stringify({ lifecycle: "active", active: true }));
289
+ const oldDate = new Date("2026-05-01T00:00:00Z");
290
+ const newDate = new Date("2026-05-02T00:00:00Z");
291
+ utimesSync(oldState, oldDate, oldDate);
292
+ utimesSync(newState, newDate, newDate);
293
+
294
+ runBash("scripts/harness-company-block.sh", dir, ["needs-key"]);
295
+ expect(JSON.parse(readFileSync(oldState, "utf8")).lifecycle).toBe("active");
296
+ expect(JSON.parse(readFileSync(newState, "utf8")).lifecycle).toBe("blocked");
297
+
298
+ runBash("scripts/harness-company-complete.sh", dir, ["provided-key"]);
299
+ expect(JSON.parse(readFileSync(oldState, "utf8")).lifecycle).toBe("active");
300
+ expect(JSON.parse(readFileSync(newState, "utf8")).lifecycle).toBe("complete");
301
+ });
270
302
  });
@@ -123,6 +123,61 @@ describe("readHarnessState", () => {
123
123
  expect(mission.protocolViolations).toContain("cto:missing-worker-evidence");
124
124
  });
125
125
 
126
+ it("does not count roster-only hired workers as CXX evidence without a report file", () => {
127
+ const harnessDir = path.join(dir, ".harness");
128
+ const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
129
+ mkdirSync(path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker"), { recursive: true });
130
+ mkdirSync(missionDir, { recursive: true });
131
+ writeFileSync(path.join(missionDir, "ceo.md"), "# Dashboard\n");
132
+ writeFileSync(
133
+ path.join(missionDir, "cto.md"),
134
+ "# CTO\n\nWorker Evidence Manifest: cto/workers/react-ui-worker.md\n"
135
+ );
136
+ writeFileSync(
137
+ path.join(harnessDir, "shared", "HR-Resource", "react-ui-worker", "SKILL.md"),
138
+ "# React UI Worker\n"
139
+ );
140
+ writeFileSync(
141
+ path.join(harnessDir, "shared", "hr-roster.json"),
142
+ JSON.stringify({
143
+ hired: [
144
+ {
145
+ worker: "react-ui-worker",
146
+ owner: "cto",
147
+ skillPath: ".harness/shared/HR-Resource/react-ui-worker/SKILL.md",
148
+ },
149
+ ],
150
+ })
151
+ );
152
+
153
+ const mission = readHarnessState(dir).missions[0];
154
+ expect(mission.workers[0]).toMatchObject({ name: "react-ui-worker", reportPath: null });
155
+ expect(mission.protocolViolations).toContain("cto:missing-worker-evidence");
156
+ });
157
+
158
+ it("keeps an explicit active mission visible even when it is older than the latest page", () => {
159
+ const harnessDir = path.join(dir, ".harness");
160
+ const docsDir = path.join(harnessDir, "documents");
161
+ const activeDir = path.join(docsDir, "goal-0-operating");
162
+ mkdirSync(activeDir, { recursive: true });
163
+ writeFileSync(path.join(activeDir, "ceo.md"), "# Operating\n");
164
+ writeFileSync(path.join(activeDir, "mission-state.json"), JSON.stringify({ lifecycle: "operating", active: true }));
165
+ const oldDate = new Date("2026-05-01T00:00:00Z");
166
+ utimesSync(activeDir, oldDate, oldDate);
167
+
168
+ for (let i = 1; i <= 16; i += 1) {
169
+ const missionDir = path.join(docsDir, `goal-${i}-done`);
170
+ mkdirSync(missionDir, { recursive: true });
171
+ writeFileSync(path.join(missionDir, "ceo.md"), `# Done ${i}\n`);
172
+ writeFileSync(path.join(missionDir, "mission-state.json"), JSON.stringify({ lifecycle: "complete", active: false }));
173
+ const ts = new Date(`2026-05-${String(i + 1).padStart(2, "0")}T00:00:00Z`);
174
+ utimesSync(missionDir, ts, ts);
175
+ }
176
+
177
+ const missions = readHarnessState(dir).missions;
178
+ expect(missions.some((mission) => mission.missionId === "goal-0-operating" && mission.active)).toBe(true);
179
+ });
180
+
126
181
  it("positions only hired HR-Resource workers and marks active workers", () => {
127
182
  const harnessDir = path.join(dir, ".harness");
128
183
  const missionDir = path.join(harnessDir, "documents", "goal-1-dashboard");
@@ -8,7 +8,7 @@
8
8
  * the values the live Scene.tsx renders, plus the Stop-hook autonomy behavior.
9
9
  *
10
10
  * Gated behind WALWAL_E2E=1 so it stays out of the fast unit suite and only
11
- * runs (and writes to ~/tmp) when explicitly requested:
11
+ * runs (and writes to the OS temp dir, or WALWAL_E2E_ROOT when set) when explicitly requested:
12
12
  * WALWAL_E2E=1 npx vitest run sandbox-e2e
13
13
  */
14
14
  import { describe, it, expect, beforeAll } from "vitest";
@@ -111,7 +111,7 @@ function stopTick(sbx: string): { blocked: boolean; raw: string } {
111
111
  return { blocked: raw.includes('"decision"') && raw.includes('"block"'), raw };
112
112
  }
113
113
 
114
- const SBX_ROOT = path.join(os.homedir(), "tmp");
114
+ const SBX_ROOT = process.env.WALWAL_E2E_ROOT ?? path.join(os.tmpdir(), "walwal-e2e");
115
115
 
116
116
  gate("walwal-harness sandbox e2e — two usage modes", () => {
117
117
  beforeAll(() => {
@@ -135,6 +135,71 @@ gate("walwal-harness sandbox e2e — two usage modes", () => {
135
135
  expect(lstatSync(path.join(sbx, "CLAUDE.md")).isSymbolicLink()).toBe(true);
136
136
  });
137
137
 
138
+ it("migrate preserves user AGENTS.md and hired roster while refreshing package-owned state", () => {
139
+ const sbx = path.join(SBX_ROOT, "walwal-sandbox-migrate-preserve");
140
+ init(sbx);
141
+ appendFileSync(path.join(sbx, "AGENTS.md"), "\n## User Rule\n\nKeep this project-specific rule.\n");
142
+ writeFileSync(path.join(sbx, ".harness", ".bundle-version"), "7.1.1\n");
143
+ mkdirSync(path.join(sbx, ".harness", "shared", "HR-Resource", "react-ui-worker"), { recursive: true });
144
+ writeFileSync(path.join(sbx, ".harness", "shared", "HR-Resource", "react-ui-worker", "SKILL.md"), "# React UI Worker\n");
145
+ const rosterPath = path.join(sbx, ".harness", "shared", "hr-roster.json");
146
+ writeFileSync(
147
+ rosterPath,
148
+ JSON.stringify(
149
+ {
150
+ hired: [
151
+ {
152
+ worker: "react-ui-worker",
153
+ owner: "cto",
154
+ skillPath: ".claude/skills/react-ui-worker/SKILL.md",
155
+ },
156
+ ],
157
+ },
158
+ null,
159
+ 2
160
+ )
161
+ );
162
+
163
+ execFileSync("node", [path.join(repoRoot, "bin", "init.js"), "migrate", "--dry-run", "--project-root", sbx], {
164
+ cwd: repoRoot,
165
+ encoding: "utf8",
166
+ stdio: "pipe",
167
+ });
168
+ expect(readFileSync(path.join(sbx, ".harness", ".bundle-version"), "utf8")).toBe("7.1.1\n");
169
+ expect(JSON.parse(readFileSync(rosterPath, "utf8")).hired[0].skillPaths).toBeUndefined();
170
+
171
+ execFileSync("node", [path.join(repoRoot, "bin", "init.js"), "migrate", "--project-root", sbx], {
172
+ cwd: repoRoot,
173
+ encoding: "utf8",
174
+ stdio: "pipe",
175
+ });
176
+ const agents = readFileSync(path.join(sbx, "AGENTS.md"), "utf8");
177
+ expect(agents).toContain("Keep this project-specific rule.");
178
+ const roster = JSON.parse(readFileSync(rosterPath, "utf8"));
179
+ expect(roster.hired[0].skillPaths.codex).toContain(".codex/skills/");
180
+ expect(roster.hired[0].skillPaths.source).toBe(".harness/shared/HR-Resource/react-ui-worker/SKILL.md");
181
+ expect(readFileSync(path.join(sbx, ".harness", ".bundle-version"), "utf8")).toBe("7.1.48\n");
182
+ });
183
+
184
+ it("gitignore management untracks runtime state without untracking shared harness files", () => {
185
+ const sbx = path.join(SBX_ROOT, "walwal-sandbox-gitignore");
186
+ init(sbx);
187
+ execFileSync("git", ["init"], { cwd: sbx, encoding: "utf8", stdio: "pipe" });
188
+ execFileSync("git", ["add", "-f", ".harness/progress.json"], { cwd: sbx, encoding: "utf8", stdio: "pipe" });
189
+ execFileSync("git", ["add", ".harness/memory.md"], { cwd: sbx, encoding: "utf8", stdio: "pipe" });
190
+ expect(execFileSync("git", ["ls-files", ".harness/progress.json"], { cwd: sbx, encoding: "utf8" }).trim()).toBe(".harness/progress.json");
191
+ expect(execFileSync("git", ["ls-files", ".harness/memory.md"], { cwd: sbx, encoding: "utf8" }).trim()).toBe(".harness/memory.md");
192
+
193
+ execFileSync("node", [path.join(repoRoot, "bin", "init.js"), "init", "--force", "--project-root", sbx], {
194
+ cwd: repoRoot,
195
+ encoding: "utf8",
196
+ stdio: "pipe",
197
+ });
198
+
199
+ expect(execFileSync("git", ["ls-files", ".harness/progress.json"], { cwd: sbx, encoding: "utf8" }).trim()).toBe("");
200
+ expect(execFileSync("git", ["ls-files", ".harness/memory.md"], { cwd: sbx, encoding: "utf8" }).trim()).toBe(".harness/memory.md");
201
+ });
202
+
138
203
  // ───────────────────────────────────────────────────────────────────────
139
204
  // MODE 1 — AUTONOMY: Owner sets a broad goal, the company runs itself, and
140
205
  // the loop must (a) keep going while work remains and (b) stop CLEANLY at
@@ -186,7 +186,11 @@ export function toContractState(
186
186
  // ---- agents --------------------------------------------------------
187
187
  const hasRecentSample = (role: AgentRole): boolean =>
188
188
  snapshot.activitySamples.some(
189
- (s) => (s.laneId === role || s.laneId.startsWith(role + ":")) && s.count > 0
189
+ (s) => {
190
+ if ((s.laneId !== role && !s.laneId.startsWith(role + ":")) || s.count <= 0) return false;
191
+ const t = parseTs(s.ts);
192
+ return t != null && nowMs - t <= STALE_MS;
193
+ }
190
194
  );
191
195
  const runtimeBlocked =
192
196
  (rt.agentStatus ?? "").toLowerCase() === "blocked" ||
@@ -615,7 +615,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
615
615
  const protocolViolations: string[] = [];
616
616
  for (const role of cxxPresent) {
617
617
  if (role === "ceo") continue;
618
- const roleWorkers = workers.filter((w) => w.owner === role);
618
+ const roleWorkers = workers.filter((w) => w.owner === role && w.reportPath);
619
619
  const roleDoc = readMd(`${role}.md`) ?? "";
620
620
  const hasManifest = /Worker Evidence Manifest/i.test(roleDoc);
621
621
  const mentionsWorkerReport = new RegExp(`${role}/workers/`, "i").test(roleDoc);
@@ -677,7 +677,17 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
677
677
  }
678
678
  }
679
679
 
680
- missions = missions.slice(0, limit);
680
+ const activePinned = missions.filter((m) => m.active);
681
+ const limited = missions.slice(0, limit);
682
+ if (activePinned.length) {
683
+ const seen = new Set(limited.map((m) => m.missionId));
684
+ for (const mission of activePinned) {
685
+ if (!seen.has(mission.missionId)) limited.push(mission);
686
+ }
687
+ missions = limited;
688
+ } else {
689
+ missions = limited;
690
+ }
681
691
 
682
692
  // When the CEO updates a goal directory in-place for a submission or hot-fix
683
693
  // (instead of creating a submission-* or hotfix-* subdirectory), the most
@@ -1,9 +1,7 @@
1
1
  /** @type {import('next').NextConfig} */
2
2
  const nextConfig = {
3
3
  reactStrictMode: true,
4
- experimental: {
5
- typedRoutes: false,
6
- },
4
+ typedRoutes: false,
7
5
  };
8
6
 
9
7
  export default nextConfig;
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "Brick Office — walwal-harness 라이브 대시보드 (Phase C)",
6
6
  "scripts": {
7
- "dev:dashboard": "next dev -p 3001",
7
+ "dev:dashboard": "next dev",
8
8
  "build": "next build",
9
9
  "start": "next start -p 3001",
10
10
  "lint": "next lint",
package/bin/init.js CHANGED
@@ -20,6 +20,7 @@ const subcommandArgs = args.filter(a => !a.startsWith('-') && a !== subcommand);
20
20
  const isAuto = args.includes('--auto');
21
21
  const isForce = args.includes('--force');
22
22
  const isHelp = args.includes('--help') || args.includes('-h');
23
+ const isWakeEnabled = args.includes('--enable-wake') || process.env.WALWAL_ENABLE_WAKE === '1';
23
24
 
24
25
  function getOptionValue(name) {
25
26
  const eqPrefix = `${name}=`;
@@ -1059,7 +1060,10 @@ function refreshScriptsForMigrate({ dryRun = false, backupDir = null } = {}) {
1059
1060
  chmodShellScripts(scriptsDest);
1060
1061
  }
1061
1062
 
1062
- function ensureWakeScheduler({ dryRun = false } = {}) {
1063
+ function ensureWakeScheduler({ dryRun = false, enabled = false } = {}) {
1064
+ if (!enabled || process.env.WALWAL_SKIP_WAKE === '1') {
1065
+ return;
1066
+ }
1063
1067
  const wakeInstall = path.join(PROJECT_ROOT, 'scripts', 'harness-wake-install.sh');
1064
1068
  if (!fs.existsSync(wakeInstall)) return;
1065
1069
 
@@ -1589,6 +1593,7 @@ Usage:
1589
1593
  Runtime (always-on company mode):
1590
1594
  - Stop 훅이 매 turn 종료 시 다음 부서로 자동 연쇄.
1591
1595
  - 1시간 안전망 wake (선택): bash scripts/harness-wake-install.sh install .
1596
+ init/migrate 중 자동 등록하려면 --enable-wake 를 명시하세요.
1592
1597
  - 3D 대시보드 (선택): bash scripts/harness-dashboard-up.sh
1593
1598
  - Owner 입력은 GOAL 모호성, submission, hot-fix, escalation, 결과 보고에만 필요합니다.
1594
1599
 
@@ -2150,7 +2155,7 @@ function runMigrate(opts = {}) {
2150
2155
  // Runtime scripts are package-owned. Migrate must refresh them so existing
2151
2156
  // projects receive wake/meeting/OPS fixes without requiring a full init.
2152
2157
  refreshScriptsForMigrate({ dryRun, backupDir });
2153
- ensureWakeScheduler({ dryRun });
2158
+ ensureWakeScheduler({ dryRun, enabled: opts.enableWake === true });
2154
2159
  migrateActivityHistory({ dryRun });
2155
2160
  updateRuleRegistryLinks(flags.ruleRegistryLinks || [], { dryRun, backupDir });
2156
2161
 
@@ -2685,7 +2690,7 @@ function main() {
2685
2690
  }
2686
2691
 
2687
2692
  if (subcommand === 'migrate') {
2688
- runMigrate({ dryRun: args.includes('--dry-run') });
2693
+ runMigrate({ dryRun: args.includes('--dry-run'), enableWake: isWakeEnabled });
2689
2694
  return;
2690
2695
  }
2691
2696
 
@@ -2721,7 +2726,7 @@ function main() {
2721
2726
  gitUntrackLocalState();
2722
2727
  installSkills();
2723
2728
  installScripts();
2724
- ensureWakeScheduler();
2729
+ ensureWakeScheduler({ enabled: isWakeEnabled });
2725
2730
  installCommands();
2726
2731
  installSessionHook();
2727
2732
  installStatusline();
@@ -2782,7 +2787,7 @@ function main() {
2782
2787
  console.log('');
2783
2788
 
2784
2789
  if (subcommand === 'init' && subcommandArgs.includes('migrate')) {
2785
- runMigrate({ dryRun: args.includes('--dry-run') });
2790
+ runMigrate({ dryRun: args.includes('--dry-run'), enableWake: isWakeEnabled });
2786
2791
  }
2787
2792
  }
2788
2793
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.48",
3
+ "version": "7.1.49",
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"
@@ -31,6 +31,27 @@ command -v jq >/dev/null 2>&1 || {
31
31
  exit 1
32
32
  }
33
33
 
34
+ state_mtime() {
35
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
36
+ }
37
+
38
+ pick_active_mission_state() {
39
+ local best=""
40
+ local best_mtime=0
41
+ local state active mtime
42
+ [ -d "$DOCS" ] || return 0
43
+ while IFS= read -r -d '' state; do
44
+ active="$(jq -r '.active // false' "$state" 2>/dev/null || echo false)"
45
+ [ "$active" = "true" ] || continue
46
+ mtime="$(state_mtime "$state")"
47
+ if [ "${mtime:-0}" -ge "$best_mtime" ]; then
48
+ best="$state"
49
+ best_mtime="${mtime:-0}"
50
+ fi
51
+ done < <(find "$DOCS" -name mission-state.json -type f -print0)
52
+ [ -n "$best" ] && printf '%s\n' "$best"
53
+ }
54
+
34
55
  if ! bash "$SCRIPT_DIR/harness-progress-set.sh" "$PROJECT_ROOT" \
35
56
  '.company_state.state = "idle" |
36
57
  .conductor.state = "blocked" |
@@ -69,17 +90,17 @@ fi
69
90
 
70
91
  DOCS="$PROJECT_ROOT/.harness/documents"
71
92
  if [ -d "$DOCS" ]; then
72
- find "$DOCS" -name mission-state.json -type f -print0 | while IFS= read -r -d '' state; do
73
- active="$(jq -r '.active // false' "$state" 2>/dev/null || echo false)"
74
- lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$state" 2>/dev/null || echo unknown)"
93
+ target_state="$(pick_active_mission_state)"
94
+ if [ -n "$target_state" ]; then
95
+ lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$target_state" 2>/dev/null || echo unknown)"
75
96
  case "$lifecycle" in
76
- closed|cancelled|superseded|complete|completed|blocked) continue ;;
97
+ closed|cancelled|superseded|complete|completed|blocked) ;;
98
+ *)
99
+ tmp="$(mktemp)"
100
+ jq --arg r "$REASON" '.lifecycle = "blocked" | .active = false | .blocked_reason = $r | .blocked_at = (now | todate)' "$target_state" > "$tmp" && mv "$tmp" "$target_state"
101
+ ;;
77
102
  esac
78
- if [ "$active" = "true" ]; then
79
- tmp="$(mktemp)"
80
- jq --arg r "$REASON" '.lifecycle = "blocked" | .active = false | .blocked_reason = $r | .blocked_at = (now | todate)' "$state" > "$tmp" && mv "$tmp" "$state"
81
- fi
82
- done
103
+ fi
83
104
  fi
84
105
 
85
106
  if command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/harness-activity-record.js" ]; then
@@ -25,6 +25,29 @@ command -v jq >/dev/null 2>&1 || {
25
25
  exit 1
26
26
  }
27
27
 
28
+ state_mtime() {
29
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
30
+ }
31
+
32
+ pick_transition_mission_state() {
33
+ local best=""
34
+ local best_mtime=0
35
+ local state active lifecycle mtime
36
+ [ -d "$DOCS" ] || return 0
37
+ while IFS= read -r -d '' state; do
38
+ active="$(jq -r '.active // false' "$state" 2>/dev/null || echo false)"
39
+ lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$state" 2>/dev/null || echo unknown)"
40
+ if [ "$active" = "true" ] || [ "$lifecycle" = "blocked" ]; then
41
+ mtime="$(state_mtime "$state")"
42
+ if [ "${mtime:-0}" -ge "$best_mtime" ]; then
43
+ best="$state"
44
+ best_mtime="${mtime:-0}"
45
+ fi
46
+ fi
47
+ done < <(find "$DOCS" -name mission-state.json -type f -print0)
48
+ [ -n "$best" ] && printf '%s\n' "$best"
49
+ }
50
+
28
51
  if ! bash "$SCRIPT_DIR/harness-progress-set.sh" "$PROJECT_ROOT" \
29
52
  '.company_state.state = "idle" |
30
53
  .company_state.active_workers = 0 |
@@ -64,19 +87,17 @@ fi
64
87
 
65
88
  DOCS="$PROJECT_ROOT/.harness/documents"
66
89
  if [ -d "$DOCS" ]; then
67
- find "$DOCS" -name mission-state.json -type f -print0 | while IFS= read -r -d '' state; do
68
- active="$(jq -r '.active // false' "$state" 2>/dev/null || echo false)"
69
- lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$state" 2>/dev/null || echo unknown)"
90
+ target_state="$(pick_transition_mission_state)"
91
+ if [ -n "$target_state" ]; then
92
+ lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$target_state" 2>/dev/null || echo unknown)"
70
93
  case "$lifecycle" in
71
- closed|cancelled|superseded|complete|completed) continue ;;
94
+ closed|cancelled|superseded|complete|completed) ;;
95
+ *)
96
+ tmp="$(mktemp)"
97
+ jq '.lifecycle = "complete" | .active = false | .completed_at = (now | todate) | del(.blocked_reason) | del(.blocked_at)' "$target_state" > "$tmp" && mv "$tmp" "$target_state"
98
+ ;;
72
99
  esac
73
- # A mission left "blocked" (active=false) is resumed and completed here, so
74
- # clear its blocker fields too — block -> complete must leave no stale state.
75
- if [ "$active" = "true" ] || [ "$lifecycle" = "blocked" ]; then
76
- tmp="$(mktemp)"
77
- jq '.lifecycle = "complete" | .active = false | .completed_at = (now | todate) | del(.blocked_reason) | del(.blocked_at)' "$state" > "$tmp" && mv "$tmp" "$state"
78
- fi
79
- done
100
+ fi
80
101
  fi
81
102
 
82
103
  if command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/harness-activity-record.js" ]; then
@@ -130,4 +130,4 @@ echo "║ 실시간 (SSE) 시각화합니다. Ctrl+C 로 종료.
130
130
  echo "╚══════════════════════════════════════════════════════════╝"
131
131
  echo ""
132
132
 
133
- HARNESS_ROOT="${HARNESS_ROOT}" npm run dev:dashboard -- -p "${PORT}"
133
+ HARNESS_ROOT="${HARNESS_ROOT}" npm run dev:dashboard -- -H 127.0.0.1 -p "${PORT}"