@walwal-harness/cli 7.1.28 → 7.1.30

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.
@@ -793,6 +793,44 @@ interface HiredWorkerEntry {
793
793
  capability: string | null;
794
794
  }
795
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
+
796
834
  function deriveWorkerProgress(worker: RawWorker): number | null {
797
835
  const status = (worker?.status ?? worker?.spawn_status ?? "").toLowerCase();
798
836
  if (status === "blocked" || status === "failed") return 0.35;
@@ -940,8 +978,20 @@ function workerDisplayName(content: string, hired: HiredWorkerEntry | undefined,
940
978
  return fallback;
941
979
  }
942
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
+
943
992
  function activeWorkerNames(progress: RawProgress | null): Set<string> {
944
993
  const active = new Set<string>();
994
+ if (isHarnessRuntimeIdle(progress)) return active;
945
995
  const workers = [
946
996
  ...(progress?.company_state?.workers ?? []),
947
997
  ...(progress?.company_state?.last_dispatch ?? []),
@@ -1111,6 +1161,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1111
1161
  if (!existsSync(docsDir)) return [];
1112
1162
  const hiredWorkers = readHiredWorkers(rootDir);
1113
1163
  const activeWorkers = activeWorkerNames(progress);
1164
+ const runtimeIdle = isHarnessRuntimeIdle(progress);
1114
1165
 
1115
1166
  const missionDirs: Array<{ rel: string; abs: string }> = [];
1116
1167
  const cxxDocNames = new Set(["ceo.md", "cto.md", "cqo.md", "coo.md", "cdo.md", "ops.md"]);
@@ -1145,7 +1196,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1145
1196
  return "unknown";
1146
1197
  };
1147
1198
 
1148
- const missions = missionDirs
1199
+ let missions = missionDirs
1149
1200
  .map(({ rel, abs: missionPath }) => {
1150
1201
  let mtime: Date;
1151
1202
  try { mtime = statSync(missionPath).mtime; } catch { mtime = new Date(0); }
@@ -1192,7 +1243,7 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1192
1243
  status,
1193
1244
  owner: hired?.owner ?? owner,
1194
1245
  hired: !!hired,
1195
- active: activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE"),
1246
+ active: !runtimeIdle && (activeWorkers.has(name) || (recentlyTouched && status !== "COMPLETE")),
1196
1247
  sourcePath: hired?.sourcePath ?? null,
1197
1248
  reportPath: path.relative(rootDir, reportAbs),
1198
1249
  updatedAt,
@@ -1216,10 +1267,10 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1216
1267
  name: hired.name,
1217
1268
  displayName: workerDisplayName("", hired, hired.name),
1218
1269
  content: "",
1219
- status: activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
1270
+ status: !runtimeIdle && activeWorkers.has(hired.name) ? "IN_PROGRESS" : "unknown",
1220
1271
  owner: hired.owner,
1221
1272
  hired: true,
1222
- active: activeWorkers.has(hired.name),
1273
+ active: !runtimeIdle && activeWorkers.has(hired.name),
1223
1274
  sourcePath: hired.sourcePath,
1224
1275
  reportPath: null,
1225
1276
  updatedAt: null,
@@ -1230,6 +1281,23 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1230
1281
 
1231
1282
  const cxxRoles = ["ceo", "cto", "cqo", "coo", "cdo", "ops"] as const;
1232
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
+ }
1233
1301
 
1234
1302
  const id = rel;
1235
1303
  const label = rel.split("/").pop() ?? rel;
@@ -1237,6 +1305,9 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1237
1305
  missionId: id,
1238
1306
  ts: mtime.toISOString(),
1239
1307
  type: typeFromId(id),
1308
+ lifecycle: state.lifecycle,
1309
+ active: state.active ?? false,
1310
+ protocolViolations,
1240
1311
  label,
1241
1312
  ceo: readMd("ceo.md"),
1242
1313
  cto: readMd("cto.md"),
@@ -1249,8 +1320,22 @@ function readMissions(rootDir: string, progress: RawProgress | null = null, limi
1249
1320
  cxxPresent: [...cxxPresent],
1250
1321
  } satisfies MissionDoc;
1251
1322
  })
1252
- .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
1253
- .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);
1254
1339
 
1255
1340
  // When the CEO updates a goal directory in-place for a submission or hot-fix
1256
1341
  // (instead of creating a submission-* or hotfix-* subdirectory), the most
@@ -340,6 +340,9 @@ export interface MissionDoc {
340
340
  missionId: string;
341
341
  ts: string;
342
342
  type: "goal" | "submission" | "hotfix" | "feature" | "unknown";
343
+ lifecycle: "active" | "closed" | "cancelled" | "superseded" | "complete" | "blocked" | "unknown";
344
+ active: boolean;
345
+ protocolViolations: string[];
343
346
  label: string;
344
347
  ceo: string | null;
345
348
  cto: string | null;
@@ -88,7 +88,7 @@ The Owner is the final acceptance reviewer, not a tester, QA worker, debugger, o
88
88
  4. CEO aggregates CXX outputs, routes to next CXX or reports to Owner.
89
89
  5. If the mission uses a runnable local, preview, Docker, or production-like environment, OPS watches the runtime while CQO evaluator workers run Playwright/E2E/API/visual/performance/regression checks.
90
90
 
91
- **Complete when:** `.harness/documents/goal-{index}-{name}/ceo.md` contains a final Owner report.
91
+ **Complete when:** `.harness/documents/goal-{index}-{name}/ceo.md` contains a final Owner report and `mission-state.json` is terminal (`complete`, `closed`, `cancelled`, or `superseded`) with `active:false`.
92
92
 
93
93
  ### Submission
94
94
 
@@ -98,7 +98,7 @@ The Owner is the final acceptance reviewer, not a tester, QA worker, debugger, o
98
98
  4. Relevant CXX update `.harness/conventions/` when the new requirement changes durable project rules.
99
99
  5. Workers report under `{owning-cxx}/workers/`; CXX summarize choices in `{cxx}.md`.
100
100
 
101
- **Complete when:** the submission child directory has CEO/CXX records, required worker evidence, and any convention updates required by the new requirement.
101
+ **Complete when:** the submission child directory has CEO/CXX records, required worker evidence, any convention updates required by the new requirement, and terminal `mission-state.json`.
102
102
 
103
103
  ### Hot Fix
104
104
 
@@ -150,9 +150,10 @@ Note: A docmeta skip decision on harness documents (ceo.md, cto.md, cqo.md, work
150
150
  11. **Lazy convention/gotcha loading** — CXX roles read `.harness/conventions/shared.md`, `.harness/conventions/{cxx}.md`, `.harness/gotchas/shared.md`, and `.harness/gotchas/{cxx}.md`, then follow only the related topic links listed in those CXX files. Workers read only the related links supplied by their owning CXX.
151
151
  12. **Implementation Notes required** — `ceo.md`, every `{cxx}.md`, and every worker report must end with one English `## Implementation Notes` section containing `Design Decisions`, `Deviations`, `Tradeoffs`, and `Open Questions`. Use `None` for empty subsections. Do not create a separate sidecar notes file; the notes belong at the bottom of the same role or worker report that produced the decision/evidence.
152
152
  13. **Structured runtime state** — If the harness must parse it, record it in JSON or JSONL. CXX todo queues, event history, heartbeat timestamps, preemption/resume state, and completion evidence belong in `.harness/todos/*.json*` or `.harness/events.jsonl`, not in free-form Markdown tables. Markdown remains for instructions, conventions, gotchas, skills, and human-readable mission narrative.
153
- 14. **Owner is final acceptance only** — Owner is not a tester or QA substitute. CEO/CXX must not report "done, please check" until worker-backed verification proves the goal can be completed. Use unit tests, E2E, Playwright, test accounts, seeded data, build/run checks, logs, and CQO evidence before requesting Owner acceptance.
154
- 15. **OPS watches CQO verification** — For runnable products, CQO PASS is invalid unless OPS has monitored the verification runtime or documented why OPS watch is not applicable. Open OPS incidents, missing runtime mapping, required log gaps, service down, health mismatch, or an unmonitored tested service block Owner acceptance.
155
- 16. **Production incidents are company events** — After launch, user-impacting OPS signals route through CEO to CTO/CQO/OPS. CTO owns recovery, CQO owns regression confirmation, and OPS owns evidence plus close criteria.
153
+ 14. **Mission lifecycle is explicit** — Every goal, submission, and hot-fix directory must contain `mission-state.json` with `lifecycle` and `active`. Only one child mission under a goal may be active. Starting a newer submission/hot-fix closes, cancels, or supersedes the previous active child unless CEO records a deliberate TODO/resume plan.
154
+ 15. **Owner is final acceptance only** — Owner is not a tester or QA substitute. CEO/CXX must not report "done, please check" until worker-backed verification proves the goal can be completed. Use unit tests, E2E, Playwright, test accounts, seeded data, build/run checks, logs, and CQO evidence before requesting Owner acceptance.
155
+ 16. **OPS watches CQO verification** — For runnable products, CQO PASS is invalid unless OPS has monitored the verification runtime or documented why OPS watch is not applicable. Open OPS incidents, missing runtime mapping, required log gaps, service down, health mismatch, or an unmonitored tested service block Owner acceptance.
156
+ 17. **Production incidents are company events** — After launch, user-impacting OPS signals route through CEO to CTO/CQO/OPS. CTO owns recovery, CQO owns regression confirmation, and OPS owns evidence plus close criteria.
156
157
 
157
158
  ---
158
159
 
package/bin/init.js CHANGED
@@ -112,6 +112,22 @@ function chmodShellScripts(dir) {
112
112
  }
113
113
  }
114
114
 
115
+ function readJsonFileSafe(filePath) {
116
+ try {
117
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function readTextFileSafe(filePath) {
124
+ try {
125
+ return fs.readFileSync(filePath, 'utf8');
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
115
131
  function fileExists(p) {
116
132
  return fs.existsSync(p);
117
133
  }
@@ -451,6 +467,99 @@ function migrateExistingDocs() {
451
467
  log(`Migration report: ${reportPath}`);
452
468
  }
453
469
 
470
+ function missionTypeFromDirName(name) {
471
+ if (/^goal[-_]/i.test(name)) return 'goal';
472
+ if (/^submission[-_]/i.test(name)) return 'submission';
473
+ if (/^hot[-_]?fix[-_]/i.test(name) || /^hotfix[-_]/i.test(name)) return 'hotfix';
474
+ return 'unknown';
475
+ }
476
+
477
+ function isTerminalMissionLifecycle(value) {
478
+ return ['closed', 'cancelled', 'superseded', 'complete', 'blocked'].includes(String(value || '').toLowerCase());
479
+ }
480
+
481
+ function collectMissionDirs(docsDir) {
482
+ const cxxDocs = new Set(['ceo.md', 'coo.md', 'cdo.md', 'cto.md', 'cqo.md', 'ops.md']);
483
+ const result = [];
484
+ const walk = (dir, rel = '') => {
485
+ if (!fs.existsSync(dir)) return;
486
+ let entries = [];
487
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
488
+ const hasMissionDoc = entries.some((entry) => entry.isFile() && cxxDocs.has(entry.name));
489
+ if (rel && hasMissionDoc) {
490
+ let mtime = 0;
491
+ try { mtime = fs.statSync(dir).mtimeMs; } catch {}
492
+ const name = path.basename(rel);
493
+ result.push({ abs: dir, rel, name, type: missionTypeFromDirName(name), mtime });
494
+ }
495
+ for (const entry of entries) {
496
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
497
+ if (['workers', 'coo', 'cdo', 'cto', 'cqo', 'ops'].includes(entry.name)) continue;
498
+ walk(path.join(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name);
499
+ }
500
+ };
501
+ walk(docsDir);
502
+ return result;
503
+ }
504
+
505
+ function ensureMissionLifecycleStates(opts = {}) {
506
+ const dryRun = opts.dryRun || false;
507
+ const backupDir = opts.backupDir || null;
508
+ const docsDir = path.join(HARNESS_DIR, 'documents');
509
+ if (!fs.existsSync(docsDir)) return { missing: 0, written: 0 };
510
+
511
+ const missions = collectMissionDirs(docsDir);
512
+ if (!missions.length) return { missing: 0, written: 0 };
513
+
514
+ const byGoal = new Map();
515
+ for (const mission of missions) {
516
+ const goalKey = mission.rel.split('/')[0];
517
+ if (!byGoal.has(goalKey)) byGoal.set(goalKey, []);
518
+ byGoal.get(goalKey).push(mission);
519
+ }
520
+
521
+ const writes = [];
522
+ for (const group of byGoal.values()) {
523
+ const children = group.filter((m) => m.rel.includes('/') && (m.type === 'submission' || m.type === 'hotfix'));
524
+ children.sort((a, b) => b.mtime - a.mtime);
525
+ const existingActiveChild = children.find((m) => {
526
+ const state = readJsonFileSafe(path.join(m.abs, 'mission-state.json'));
527
+ return state?.active === true && !isTerminalMissionLifecycle(state.lifecycle || state.status);
528
+ });
529
+ const activeChild = existingActiveChild || children[0] || null;
530
+
531
+ for (const mission of group) {
532
+ const statePath = path.join(mission.abs, 'mission-state.json');
533
+ if (fs.existsSync(statePath)) continue;
534
+ const isChild = mission.rel.includes('/');
535
+ const isActive = isChild
536
+ ? activeChild?.rel === mission.rel
537
+ : true;
538
+ writes.push({
539
+ path: statePath,
540
+ state: {
541
+ lifecycle: isActive ? 'active' : 'closed',
542
+ active: isActive,
543
+ },
544
+ });
545
+ }
546
+ }
547
+
548
+ if (dryRun) return { missing: writes.length, written: 0 };
549
+
550
+ for (const item of writes) {
551
+ if (backupDir) {
552
+ const rel = path.relative(HARNESS_DIR, item.path).replace(/[\\/]/g, '__');
553
+ if (fs.existsSync(item.path)) fs.writeFileSync(path.join(backupDir, rel), fs.readFileSync(item.path, 'utf8'));
554
+ }
555
+ fs.writeFileSync(item.path, JSON.stringify(item.state, null, 2) + '\n');
556
+ }
557
+ if (writes.length) {
558
+ log(`mission-state.json: ${writes.length}개 기존 mission에 lifecycle 보강`);
559
+ }
560
+ return { missing: writes.length, written: writes.length };
561
+ }
562
+
454
563
  // ─────────────────────────────────────────
455
564
  // 1. .harness/ scaffolding
456
565
  // ─────────────────────────────────────────
@@ -585,6 +694,7 @@ function scaffoldHarness() {
585
694
  if (!fileExists(p) || isForce) fs.writeFileSync(p, '');
586
695
  }
587
696
  ensureStructuredRuntimeFiles();
697
+ ensureMissionLifecycleStates();
588
698
 
589
699
  // Migrate progress.json v1 → v2 (add mode + team_state fields)
590
700
  const progressPath = path.join(HARNESS_DIR, 'progress.json');
@@ -1094,55 +1204,129 @@ function installAgentTeamsEnv() {
1094
1204
  }
1095
1205
  }
1096
1206
 
1097
- function setupAgentsMd() {
1207
+ const HARNESS_HEADER_MARKER = '<!-- walwal-harness:managed-header -->';
1208
+ const HARNESS_SECTION_START = '<!-- walwal-harness:agents:start -->';
1209
+ const HARNESS_SECTION_END = '<!-- walwal-harness:agents:end -->';
1210
+ const PRESERVED_CLAUDE_START = '<!-- walwal-harness:preserved-claude:start -->';
1211
+ const PRESERVED_CLAUDE_END = '<!-- walwal-harness:preserved-claude:end -->';
1212
+
1213
+ function backupDocPath(filePath, backupDir, label) {
1214
+ if (!filePath || !fs.existsSync(filePath)) return;
1215
+ ensureDir(backupDir);
1216
+ const safe = label.replace(/[^A-Za-z0-9_.-]/g, '_');
1217
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1218
+ try {
1219
+ fs.copyFileSync(filePath, path.join(backupDir, `${safe}.${ts}.bak`));
1220
+ } catch {}
1221
+ }
1222
+
1223
+ function renderHarnessAgentsSection() {
1224
+ const templateSrc = path.join(PKG_ROOT, 'assets', 'templates', 'AGENTS.md.template');
1225
+ let content = fs.existsSync(templateSrc)
1226
+ ? fs.readFileSync(templateSrc, 'utf8')
1227
+ : '# walwal-harness\n\nUse `/goal`, `/submission`, and `/hot-fix` for CEO/CXX/worker flow.\n';
1228
+ content = content.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
1229
+ content = content.replace(/\{\{DATE_ISO\}\}/g, new Date().toISOString());
1230
+ return [
1231
+ HARNESS_SECTION_START,
1232
+ '',
1233
+ content.trim(),
1234
+ '',
1235
+ HARNESS_SECTION_END,
1236
+ ''
1237
+ ].join('\n');
1238
+ }
1239
+
1240
+ function ensureTopHarnessHeader(body) {
1241
+ if (body.includes(HARNESS_HEADER_MARKER)) return body;
1242
+ const header = [
1243
+ HARNESS_HEADER_MARKER,
1244
+ '> This project is walwal-harness enabled. Harness operating rules live in the marked walwal-harness section below.',
1245
+ ''
1246
+ ].join('\n');
1247
+ return header + body.replace(/^\s+/, '');
1248
+ }
1249
+
1250
+ function upsertMarkedBlock(body, start, end, block) {
1251
+ const startIdx = body.indexOf(start);
1252
+ const endIdx = body.indexOf(end);
1253
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
1254
+ const afterEnd = endIdx + end.length;
1255
+ return body.slice(0, startIdx).replace(/\s*$/, '\n\n') + block.trim() + '\n' + body.slice(afterEnd).replace(/^\s*/, '\n');
1256
+ }
1257
+ const sep = body.trim() ? '\n\n' : '';
1258
+ return body.replace(/\s*$/, '') + sep + block.trim() + '\n';
1259
+ }
1260
+
1261
+ function normalizeDocBody(body) {
1262
+ return String(body || '').replace(/\r\n/g, '\n').trim();
1263
+ }
1264
+
1265
+ function ensureProjectAgentDocs(opts = {}) {
1266
+ const dryRun = opts.dryRun || false;
1267
+ const backupDir = opts.backupDir || path.join(HARNESS_DIR, 'archive', 'pre-harness-backup');
1098
1268
  const agentsMd = path.join(PROJECT_ROOT, 'AGENTS.md');
1099
1269
  const claudeMd = path.join(PROJECT_ROOT, 'CLAUDE.md');
1100
1270
 
1101
- // Run scan if no AGENTS.md
1102
- if (!fileExists(agentsMd) || isForce) {
1103
- log('Running project scan...');
1104
- try {
1105
- execSync(`bash "${path.join(PKG_ROOT, 'scripts', 'scan-project.sh')}" "${PROJECT_ROOT}"`, {
1106
- stdio: 'inherit'
1107
- });
1108
- execSync(`bash "${path.join(PKG_ROOT, 'scripts', 'init-agents-md.sh')}" "${PROJECT_ROOT}"`, {
1109
- stdio: 'inherit'
1110
- });
1111
- } catch (e) {
1112
- log('WARNING: Auto-scan failed. Run manually: bash scripts/scan-project.sh .');
1113
- // Create minimal AGENTS.md
1114
- const templateSrc = path.join(PKG_ROOT, 'assets', 'templates', 'AGENTS.md.template');
1115
- if (fs.existsSync(templateSrc)) {
1116
- let content = fs.readFileSync(templateSrc, 'utf8');
1117
- content = content.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
1118
- fs.writeFileSync(agentsMd, content);
1119
- }
1120
- }
1271
+ const agentsTarget = fs.existsSync(agentsMd) ? fs.realpathSync(agentsMd) : agentsMd;
1272
+ const existingAgents = readTextFileSafe(agentsMd) || '';
1273
+ const claudeExists = fs.existsSync(claudeMd) || fs.existsSync(claudeMd);
1274
+ const claudeIsSymlink = fs.existsSync(claudeMd) && fs.lstatSync(claudeMd).isSymbolicLink();
1275
+ const claudeTarget = claudeExists ? fs.realpathSync(claudeMd) : null;
1276
+ const claudeBody = claudeExists ? (readTextFileSafe(claudeMd) || '') : '';
1277
+
1278
+ let nextAgents = existingAgents;
1279
+ nextAgents = ensureTopHarnessHeader(nextAgents);
1280
+ nextAgents = upsertMarkedBlock(nextAgents, HARNESS_SECTION_START, HARNESS_SECTION_END, renderHarnessAgentsSection());
1281
+
1282
+ const normalizedClaude = normalizeDocBody(claudeBody);
1283
+ const claudePointsAtAgents = claudeIsSymlink && claudeTarget === agentsTarget;
1284
+ if (normalizedClaude && !claudePointsAtAgents) {
1285
+ const preservedBlock = [
1286
+ PRESERVED_CLAUDE_START,
1287
+ '',
1288
+ '## Preserved CLAUDE.md',
1289
+ '',
1290
+ normalizedClaude,
1291
+ '',
1292
+ PRESERVED_CLAUDE_END,
1293
+ ''
1294
+ ].join('\n');
1295
+ nextAgents = upsertMarkedBlock(nextAgents, PRESERVED_CLAUDE_START, PRESERVED_CLAUDE_END, preservedBlock);
1121
1296
  }
1122
1297
 
1123
- // Ensure CLAUDE.md symlink
1124
- if (fileExists(agentsMd)) {
1125
- try {
1126
- const stat = fs.lstatSync(claudeMd);
1127
- if (!stat.isSymbolicLink()) {
1128
- // Backup existing CLAUDE.md
1129
- const backupDir = path.join(HARNESS_DIR, 'archive', 'pre-harness-backup');
1130
- ensureDir(backupDir);
1131
- fs.copyFileSync(claudeMd, path.join(backupDir, `CLAUDE.md.${Date.now()}.bak`));
1132
- fs.unlinkSync(claudeMd);
1133
- fs.symlinkSync('AGENTS.md', claudeMd);
1134
- log('CLAUDE.md backed up and replaced with symlink → AGENTS.md');
1135
- }
1136
- } catch (e) {
1137
- // CLAUDE.md doesn't exist
1138
- try {
1139
- fs.symlinkSync('AGENTS.md', claudeMd);
1140
- log('Created symlink: CLAUDE.md → AGENTS.md');
1141
- } catch (e2) {}
1142
- }
1298
+ if (dryRun) {
1299
+ log(' (dry-run) AGENTS.md: walwal-harness marked section merge 예정');
1300
+ if (!claudePointsAtAgents) log(' (dry-run) CLAUDE.md: content preserved then symlink → AGENTS.md 예정');
1301
+ return;
1302
+ }
1303
+
1304
+ if (fs.existsSync(agentsTarget)) backupDocPath(agentsTarget, backupDir, 'AGENTS.md');
1305
+ if (claudeExists && claudeTarget && claudeTarget !== agentsTarget) {
1306
+ backupDocPath(claudeTarget, backupDir, 'CLAUDE.md');
1307
+ }
1308
+
1309
+ ensureDir(path.dirname(agentsMd));
1310
+ fs.writeFileSync(agentsMd, nextAgents.replace(/\s*$/, '\n'));
1311
+ log(fs.existsSync(agentsTarget) ? 'AGENTS.md merged with walwal-harness marked section' : 'AGENTS.md created from walwal-harness template');
1312
+
1313
+ try {
1314
+ if (fs.existsSync(claudeMd) || fs.lstatSync(claudeMd)) fs.unlinkSync(claudeMd);
1315
+ } catch {}
1316
+ try {
1317
+ fs.symlinkSync('AGENTS.md', claudeMd);
1318
+ log('CLAUDE.md replaced with symlink → AGENTS.md');
1319
+ } catch (e) {
1320
+ log(`WARNING: Could not create CLAUDE.md symlink (${e.message})`);
1143
1321
  }
1144
1322
  }
1145
1323
 
1324
+ function setupAgentsMd() {
1325
+ const agentsMd = path.join(PROJECT_ROOT, 'AGENTS.md');
1326
+ const claudeMd = path.join(PROJECT_ROOT, 'CLAUDE.md');
1327
+ ensureProjectAgentDocs({ backupDir: path.join(HARNESS_DIR, 'archive', 'pre-harness-backup') });
1328
+ }
1329
+
1146
1330
  // ─────────────────────────────────────────
1147
1331
  // 5. Playwright MCP check
1148
1332
  // ─────────────────────────────────────────
@@ -1300,6 +1484,8 @@ function detectMigrationNeeded() {
1300
1484
  harnessMdStale: false,
1301
1485
  agentsMissingOpsVerificationRules: false,
1302
1486
  agentsMissingCodexAdapterRules: false,
1487
+ agentsMissingMissionLifecycleRules: false,
1488
+ missionLifecycleMissing: false,
1303
1489
  rosterCodexPathsMissing: false,
1304
1490
  resourceIndexCodexWordingMissing: false,
1305
1491
  memoryMissingSystemEntries: [],
@@ -1427,8 +1613,19 @@ function detectMigrationNeeded() {
1427
1613
  ) {
1428
1614
  flags.agentsMissingCodexAdapterRules = true;
1429
1615
  }
1616
+ if (
1617
+ !agentsBody.includes('Mission Lifecycle Rules') &&
1618
+ !agentsBody.includes('mission-state.json')
1619
+ ) {
1620
+ flags.agentsMissingMissionLifecycleRules = true;
1621
+ }
1430
1622
  } catch {}
1431
1623
  }
1624
+ const docsDir = path.join(HARNESS_DIR, 'documents');
1625
+ if (fs.existsSync(docsDir)) {
1626
+ flags.missionLifecycleMissing = collectMissionDirs(docsDir)
1627
+ .some((mission) => !fs.existsSync(path.join(mission.abs, 'mission-state.json')));
1628
+ }
1432
1629
  if (fs.existsSync(rosterPath)) {
1433
1630
  try {
1434
1631
  const roster = JSON.parse(fs.readFileSync(rosterPath, 'utf8'));
@@ -1764,6 +1961,8 @@ function runMigrate(opts = {}) {
1764
1961
  !flags.agentsMissingOpsVerificationRules &&
1765
1962
  !flags.rosterCodexPathsMissing &&
1766
1963
  !flags.resourceIndexCodexWordingMissing &&
1964
+ !flags.agentsMissingMissionLifecycleRules &&
1965
+ !flags.missionLifecycleMissing &&
1767
1966
  (!flags.memoryMissingSystemEntries || flags.memoryMissingSystemEntries.length === 0) &&
1768
1967
  gotchaMissingTotal === 0 &&
1769
1968
  conventionMissingTotal === 0 &&
@@ -1785,6 +1984,14 @@ function runMigrate(opts = {}) {
1785
1984
  const backupDir = path.join(HARNESS_DIR, 'archive', `migration-${ts}`);
1786
1985
  if (!dryRun) ensureDir(backupDir);
1787
1986
  ensureStructuredRuntimeFiles({ dryRun });
1987
+ ensureProjectAgentDocs({ dryRun, backupDir });
1988
+ flags.agentsMissingOpsVerificationRules = false;
1989
+ flags.agentsMissingCodexAdapterRules = false;
1990
+ flags.agentsMissingMissionLifecycleRules = false;
1991
+ const missionStateResult = ensureMissionLifecycleStates({ dryRun, backupDir });
1992
+ if (dryRun && missionStateResult.missing > 0) {
1993
+ log(` (dry-run) mission-state.json: ${missionStateResult.missing}개 기존 mission에 lifecycle 보강 예정`);
1994
+ }
1788
1995
 
1789
1996
  // Runtime scripts are package-owned. Migrate must refresh them so existing
1790
1997
  // projects receive wake/meeting/OPS fixes without requiring a full init.
@@ -2099,6 +2306,31 @@ function runMigrate(opts = {}) {
2099
2306
  }
2100
2307
  }
2101
2308
 
2309
+ if (flags.agentsMissingMissionLifecycleRules) {
2310
+ const agentsPath = path.join(PROJECT_ROOT, 'AGENTS.md');
2311
+ if (fs.existsSync(agentsPath)) {
2312
+ const original = fs.readFileSync(agentsPath, 'utf8');
2313
+ const block = [
2314
+ '',
2315
+ '---',
2316
+ '',
2317
+ '## walwal-harness Mission Lifecycle Rules — v7.1.28',
2318
+ '',
2319
+ '- Every goal, submission, and hot-fix directory must contain `mission-state.json` with `lifecycle` and `active`.',
2320
+ '- Only one child mission under a goal may be active. Starting a newer submission/hot-fix closes, cancels, or supersedes the previous active child unless CEO records a deliberate TODO/resume plan.',
2321
+ '- CXX completion requires a Worker Evidence Manifest plus worker report paths under `{owning-cxx}/workers/`. A CXX report without hired worker evidence is a protocol violation.',
2322
+ '- Worker execution should be shown as worker activity; CXX activity is coordination only.',
2323
+ ''
2324
+ ].join('\n');
2325
+ log(' AGENTS.md: mission lifecycle / CXX hire-only rules append');
2326
+ if (!dryRun) {
2327
+ fs.writeFileSync(path.join(backupDir, 'AGENTS.mission-lifecycle.md'), original);
2328
+ const sep = original.endsWith('\n') ? '' : '\n';
2329
+ fs.writeFileSync(agentsPath, original + sep + block);
2330
+ }
2331
+ }
2332
+ }
2333
+
2102
2334
  // 7. Bundle version stamp — record which package version was last applied.
2103
2335
  if (flags.bundleVersionStale) {
2104
2336
  const { current, installed } = flags.bundleVersionStale;
package/commands/goal.md CHANGED
@@ -14,14 +14,16 @@ Codex adapter:
14
14
 
15
15
  Required flow:
16
16
  1. Create or update a goal under `.harness/documents/goal-{goal_index}-{goal_name}/`.
17
- 2. Record CEO decisions in `.harness/documents/goal-{goal_index}-{goal_name}/ceo.md`.
18
- 3. CEO decides whether brainstorming is needed or whether CXX questions can be issued immediately.
19
- 4. CEO asks COO, CDO, CTO, and CQO only the questions needed for this goal.
20
- 5. CXX roles must start in a fresh session context. Do not let the default model impersonate a missing worker.
21
- 6. CXX roles must not directly execute specialist deliverables. They must use hired workers for research, planning, design, implementation, QA, ops checks, and documentation.
22
- 7. If a required CXX or worker skill is missing, CEO must invoke the installed `harness-hiring` skill before assigning the work.
23
- 8. CEO must require worker report paths under `.harness/documents/goal-{goal_index}-{goal_name}/{owning-cxx}/workers/` before accepting CXX completion.
24
- 9. Do not invoke internal roles through slash commands; commands are Owner entrypoints only.
17
+ 2. Write `.harness/documents/goal-{goal_index}-{goal_name}/mission-state.json` with `{"lifecycle":"active","active":true}` before routing CXX.
18
+ 3. Record CEO decisions in `.harness/documents/goal-{goal_index}-{goal_name}/ceo.md`.
19
+ 4. CEO decides whether brainstorming is needed or whether CXX questions can be issued immediately.
20
+ 5. CEO asks COO, CDO, CTO, and CQO only the questions needed for this goal.
21
+ 6. CXX roles must start in a fresh session context. Do not let the default model impersonate a missing worker.
22
+ 7. CXX roles must not directly execute specialist deliverables. They must use hired workers for research, planning, design, implementation, QA, ops checks, and documentation.
23
+ 8. If a required CXX or worker skill is missing, CEO must invoke the installed `harness-hiring` skill before assigning the work.
24
+ 9. CEO must require a Worker Evidence Manifest and worker report paths under `.harness/documents/goal-{goal_index}-{goal_name}/{owning-cxx}/workers/` before accepting CXX completion.
25
+ 10. When the goal is accepted, cancelled, superseded, blocked, or closed, update `mission-state.json` to `complete`, `cancelled`, `superseded`, `blocked`, or `closed` and set `active:false`.
26
+ 11. Do not invoke internal roles through slash commands; commands are Owner entrypoints only.
25
27
 
26
28
  Note: A goal is the company's objective. Submissions and hot-fixes that happen while pursuing it should be recorded under that goal directory.
27
29