@walwal-harness/cli 7.1.47 → 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
@@ -4,7 +4,7 @@ docmeta:
4
4
  title: walwal-harness CHANGELOG
5
5
  type: output
6
6
  createdAt: 2026-05-07T00:00:00Z
7
- updatedAt: 2026-05-07T00:00:00Z
7
+ updatedAt: 2026-06-05T00:00:00Z
8
8
  source:
9
9
  producer: agent
10
10
  skillId: harness-release
@@ -31,6 +31,11 @@ docmeta:
31
31
 
32
32
  ## Unreleased
33
33
 
34
+ ## 7.1.48 — Auto-managed `.harness` `.gitignore` + runtime untrack on init (2026-06-05)
35
+ - `walwal-harness init` now writes/refreshes a marked managed block in the project-root `.gitignore` so machine-specific harness runtime/local state is never committed and never pollutes teammates' diffs. Ignored: live conductor `progress.json`/`progress.log`/`handoff.json`/`events.jsonl`, the `todos/` live queue (`state.json` + `events.jsonl`), `actions/`, `ops/`, `activity/`, `logs/`, `archive/` migration backups, and the `dashboard/` visualization tooling. Durable team knowledge stays tracked: `conventions/`, `gotchas/`, `documents/`, `memories/`, `memory.md`, `shared/`, `config.json`, `HARNESS.md`, `.bundle-version`.
36
+ - Init also best-effort `git rm --cached`s any of those runtime paths committed before the ignore block existed (files stay on disk), fixing the "`.gitignore` added but already-tracked files keep showing as changes" case.
37
+ - Canonical ignore list now lives in `assets/templates/gitignore-append.txt` as a single source of truth, consumed by both `bin/init.js` and `scripts/harness-dashboard-up.sh` (the dashboard launcher defers to init's managed block instead of appending its own narrow one).
38
+
34
39
  ## 7.1.38 — CEO approval authority for routine operations (2026-05-31)
35
40
 
36
41
  - CEO now has explicit authority to approve reversible routine operations without Owner confirmation, including mission consolidation/supersede cleanup, monitoring cadence, briefing templates, local cron/launchd/wake automation, dashboard refresh, and Telegram/dashboard notifications that use already configured credentials.
@@ -42,7 +47,7 @@ docmeta:
42
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`.
43
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.
44
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.
45
- - 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.
46
51
 
47
52
  ## 7.1.36 — Stop hook active-loop guard + visible heatmap (2026-05-29)
48
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",
@@ -1,6 +1,26 @@
1
- # walwal-harness dashboard runtime
2
- .harness/dashboard/apps/harness-dashboard/node_modules/
3
- .harness/dashboard/apps/harness-dashboard/.next/
4
- .harness/dashboard/apps/harness-dashboard/.turbo/
5
- .harness/dashboard/apps/harness-dashboard/tsconfig.tsbuildinfo
6
- .harness/dashboard/.download/
1
+ # >>> walwal-harness managed (local/runtime state — do not edit between markers) >>>
2
+ # Auto-managed by `walwal-harness init` (refreshed on every init).
3
+ # Edit the package template assets/templates/gitignore-append.txt, not this block.
4
+ #
5
+ # SHARED with teammates (kept in git): .harness/{conventions,gotchas,documents,
6
+ # memories,shared}/ .harness/{memory.md,config.json,HARNESS.md,.bundle-version}
7
+ # Everything listed below is machine-specific runtime or local tooling — never shared.
8
+ #
9
+ # Live conductor todo-queue + transition log (heartbeats churn each tick; embeds local paths)
10
+ .harness/todos/state.json
11
+ .harness/todos/events.jsonl
12
+ # Live conductor / handoff / event state (rewritten every run)
13
+ .harness/progress.json
14
+ .harness/progress.log
15
+ .harness/handoff.json
16
+ .harness/events.jsonl
17
+ # Active-sprint, service-ops, activity heatmap & logs runtime
18
+ .harness/actions/
19
+ .harness/ops/
20
+ .harness/activity/
21
+ .harness/logs/
22
+ # Migration & pre-harness backup snapshots (local-only)
23
+ .harness/archive/
24
+ # Brick Office dashboard — local visualization tool + per-env data & build artifacts
25
+ .harness/dashboard/
26
+ # <<< walwal-harness managed <<<
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}=`;
@@ -853,6 +854,102 @@ function ensureHarnessEnv() {
853
854
  log('.env: HARNESS_BASE_PORT placeholder added — CEO/CXX must choose an available value before starting services');
854
855
  }
855
856
 
857
+ // ── .gitignore management ───────────────────────────────────────────────────
858
+ // Keep the host project's root .gitignore in sync with the harness's local/runtime
859
+ // state so high-churn conductor files, backups, and dashboard tooling never get
860
+ // committed (and pollute teammates' diffs). The canonical entry list ships in
861
+ // assets/templates/gitignore-append.txt — single source of truth, also consumed by
862
+ // scripts/harness-dashboard-up.sh.
863
+ const GITIGNORE_MARKER_BEGIN = '# >>> walwal-harness managed';
864
+ const GITIGNORE_MARKER_END = '# <<< walwal-harness managed';
865
+
866
+ function readGitignoreBlock() {
867
+ const tplPath = path.join(PKG_ROOT, 'assets', 'templates', 'gitignore-append.txt');
868
+ if (!fileExists(tplPath)) return null;
869
+ const block = fs.readFileSync(tplPath, 'utf8').replace(/\s+$/, '');
870
+ return block || null;
871
+ }
872
+
873
+ // Non-comment, non-empty lines of the managed block = the local/runtime pathspecs.
874
+ function localPathsFromBlock(block) {
875
+ return block
876
+ .split('\n')
877
+ .map((l) => l.trim())
878
+ .filter((l) => l && !l.startsWith('#'));
879
+ }
880
+
881
+ // Write or refresh the marked managed block in the project-root .gitignore.
882
+ // Idempotent: an existing block is replaced in place; everything else is preserved.
883
+ function ensureProjectGitignore() {
884
+ const block = readGitignoreBlock();
885
+ if (!block) {
886
+ log(' → .gitignore: skipped (managed template missing)');
887
+ return;
888
+ }
889
+ const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
890
+ const original = fileExists(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
891
+ const lines = original.length ? original.split('\n') : [];
892
+
893
+ const beginLine = lines.findIndex((l) => l.startsWith(GITIGNORE_MARKER_BEGIN));
894
+ let next;
895
+ if (beginLine !== -1) {
896
+ let endLine = lines.findIndex((l, i) => i >= beginLine && l.startsWith(GITIGNORE_MARKER_END));
897
+ if (endLine === -1) endLine = lines.length - 1; // tolerate a truncated block
898
+ next = [...lines.slice(0, beginLine), ...block.split('\n'), ...lines.slice(endLine + 1)].join('\n');
899
+ } else {
900
+ const head = original.replace(/\n*$/, '');
901
+ next = (head ? head + '\n\n' : '') + block + '\n';
902
+ }
903
+ if (!next.endsWith('\n')) next += '\n';
904
+ if (next !== original) {
905
+ fs.writeFileSync(gitignorePath, next);
906
+ log(beginLine !== -1
907
+ ? ' → .gitignore: refreshed walwal-harness managed block'
908
+ : ' → .gitignore: added walwal-harness runtime/local ignore block');
909
+ }
910
+ }
911
+
912
+ // Best-effort: a project that committed runtime state before the ignore block existed
913
+ // keeps showing churn until those paths leave the index. Drop them from tracking
914
+ // (files stay on disk). Never fail init over git: a non-repo / missing-git / odd state
915
+ // is a quiet no-op.
916
+ function gitUntrackLocalState() {
917
+ const block = readGitignoreBlock();
918
+ if (!block) return;
919
+ try {
920
+ const inside = execSync('git rev-parse --is-inside-work-tree', {
921
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'pipe', 'ignore'],
922
+ }).toString().trim();
923
+ if (inside !== 'true') return;
924
+ } catch {
925
+ return; // not a git repo / git unavailable
926
+ }
927
+
928
+ const removed = [];
929
+ for (const rel of localPathsFromBlock(block)) {
930
+ let tracked = '';
931
+ try {
932
+ tracked = execSync(`git ls-files -- ${shellQuote(rel)}`, {
933
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'pipe', 'ignore'],
934
+ }).toString().trim();
935
+ } catch {
936
+ continue;
937
+ }
938
+ if (!tracked) continue;
939
+ try {
940
+ execSync(`git rm -r --cached --quiet --ignore-unmatch -- ${shellQuote(rel)}`, {
941
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'ignore', 'ignore'],
942
+ });
943
+ removed.push(rel);
944
+ } catch {
945
+ // leave it tracked; non-fatal
946
+ }
947
+ }
948
+ if (removed.length) {
949
+ log(` → git: untracked ${removed.length} now-ignored runtime path(s), kept on disk (${removed.join(', ')})`);
950
+ }
951
+ }
952
+
856
953
  function ensureStructuredRuntimeFiles({ dryRun = false } = {}) {
857
954
  const todosDir = path.join(HARNESS_DIR, 'todos');
858
955
  const statePath = path.join(todosDir, 'state.json');
@@ -963,7 +1060,10 @@ function refreshScriptsForMigrate({ dryRun = false, backupDir = null } = {}) {
963
1060
  chmodShellScripts(scriptsDest);
964
1061
  }
965
1062
 
966
- function ensureWakeScheduler({ dryRun = false } = {}) {
1063
+ function ensureWakeScheduler({ dryRun = false, enabled = false } = {}) {
1064
+ if (!enabled || process.env.WALWAL_SKIP_WAKE === '1') {
1065
+ return;
1066
+ }
967
1067
  const wakeInstall = path.join(PROJECT_ROOT, 'scripts', 'harness-wake-install.sh');
968
1068
  if (!fs.existsSync(wakeInstall)) return;
969
1069
 
@@ -1493,6 +1593,7 @@ Usage:
1493
1593
  Runtime (always-on company mode):
1494
1594
  - Stop 훅이 매 turn 종료 시 다음 부서로 자동 연쇄.
1495
1595
  - 1시간 안전망 wake (선택): bash scripts/harness-wake-install.sh install .
1596
+ init/migrate 중 자동 등록하려면 --enable-wake 를 명시하세요.
1496
1597
  - 3D 대시보드 (선택): bash scripts/harness-dashboard-up.sh
1497
1598
  - Owner 입력은 GOAL 모호성, submission, hot-fix, escalation, 결과 보고에만 필요합니다.
1498
1599
 
@@ -2054,7 +2155,7 @@ function runMigrate(opts = {}) {
2054
2155
  // Runtime scripts are package-owned. Migrate must refresh them so existing
2055
2156
  // projects receive wake/meeting/OPS fixes without requiring a full init.
2056
2157
  refreshScriptsForMigrate({ dryRun, backupDir });
2057
- ensureWakeScheduler({ dryRun });
2158
+ ensureWakeScheduler({ dryRun, enabled: opts.enableWake === true });
2058
2159
  migrateActivityHistory({ dryRun });
2059
2160
  updateRuleRegistryLinks(flags.ruleRegistryLinks || [], { dryRun, backupDir });
2060
2161
 
@@ -2589,7 +2690,7 @@ function main() {
2589
2690
  }
2590
2691
 
2591
2692
  if (subcommand === 'migrate') {
2592
- runMigrate({ dryRun: args.includes('--dry-run') });
2693
+ runMigrate({ dryRun: args.includes('--dry-run'), enableWake: isWakeEnabled });
2593
2694
  return;
2594
2695
  }
2595
2696
 
@@ -2621,9 +2722,11 @@ function main() {
2621
2722
  console.log('');
2622
2723
 
2623
2724
  scaffoldHarness();
2725
+ ensureProjectGitignore();
2726
+ gitUntrackLocalState();
2624
2727
  installSkills();
2625
2728
  installScripts();
2626
- ensureWakeScheduler();
2729
+ ensureWakeScheduler({ enabled: isWakeEnabled });
2627
2730
  installCommands();
2628
2731
  installSessionHook();
2629
2732
  installStatusline();
@@ -2684,7 +2787,7 @@ function main() {
2684
2787
  console.log('');
2685
2788
 
2686
2789
  if (subcommand === 'init' && subcommandArgs.includes('migrate')) {
2687
- runMigrate({ dryRun: args.includes('--dry-run') });
2790
+ runMigrate({ dryRun: args.includes('--dry-run'), enableWake: isWakeEnabled });
2688
2791
  }
2689
2792
  }
2690
2793
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.47",
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
@@ -53,22 +53,27 @@ fi
53
53
 
54
54
  ensure_gitignore() {
55
55
  local gitignore="${HARNESS_ROOT}/.gitignore"
56
- local marker="# walwal-harness dashboard runtime"
56
+ # `walwal-harness init` owns the canonical managed block (covers .harness/dashboard/
57
+ # plus all other runtime/local state). If it's already present, nothing to add.
58
+ local managed_marker="# >>> walwal-harness managed"
59
+ if [[ -f "$gitignore" ]] && grep -Fq "$managed_marker" "$gitignore"; then
60
+ return
61
+ fi
62
+ # Prefer the package's canonical template (single source of truth shared with init.js);
63
+ # fall back to a dashboard-only block for older packages that lack the template.
64
+ local tpl="${PACKAGE_ROOT}/assets/templates/gitignore-append.txt"
57
65
  local block
58
- block="$(cat <<'EOF'
66
+ if [[ -f "$tpl" ]]; then
67
+ block="$(cat "$tpl")"
68
+ else
69
+ block="$(cat <<'EOF'
59
70
  # walwal-harness dashboard runtime
60
- .harness/dashboard/apps/harness-dashboard/node_modules/
61
- .harness/dashboard/apps/harness-dashboard/.next/
62
- .harness/dashboard/apps/harness-dashboard/.turbo/
63
- .harness/dashboard/apps/harness-dashboard/tsconfig.tsbuildinfo
64
- .harness/dashboard/.download/
71
+ .harness/dashboard/
65
72
  EOF
66
73
  )"
67
- if [[ -f "$gitignore" ]] && grep -Fq "$marker" "$gitignore"; then
68
- return
69
74
  fi
70
75
  mkdir -p "$(dirname "$gitignore")"
71
- if [[ -s "$gitignore" ]] && [[ "$(tail -c 1 "$gitignore" 2>/dev/null || true)" != "" ]]; then
76
+ if [[ -f "$gitignore" ]] && [[ -s "$gitignore" ]] && [[ "$(tail -c 1 "$gitignore" 2>/dev/null || true)" != "" ]]; then
72
77
  printf '\n' >> "$gitignore"
73
78
  fi
74
79
  printf '%s\n' "$block" >> "$gitignore"
@@ -125,4 +130,4 @@ echo "║ 실시간 (SSE) 시각화합니다. Ctrl+C 로 종료.
125
130
  echo "╚══════════════════════════════════════════════════════════╝"
126
131
  echo ""
127
132
 
128
- 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}"