coding-agent-harness 1.0.1 → 1.0.2

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.en-US.md +14 -0
  3. package/README.md +111 -86
  4. package/README.zh-CN.md +270 -0
  5. package/SKILL.md +116 -189
  6. package/docs-release/README.md +72 -5
  7. package/docs-release/architecture/overview.md +286 -28
  8. package/docs-release/architecture/overview.zh-CN.md +288 -0
  9. package/docs-release/assets/dashboard-overview-en.png +0 -0
  10. package/docs-release/assets/harness-architecture.svg +163 -0
  11. package/docs-release/assets/harness-workflow.svg +64 -0
  12. package/docs-release/guides/agent-installation.en-US.md +214 -0
  13. package/docs-release/guides/agent-installation.md +123 -26
  14. package/docs-release/guides/document-audience-and-surfaces.en-US.md +112 -0
  15. package/docs-release/guides/document-audience-and-surfaces.md +112 -0
  16. package/docs-release/guides/full-legacy-migration-subagent-strategy.md +334 -0
  17. package/docs-release/guides/full-legacy-migration-subagent-strategy.zh-CN.md +334 -0
  18. package/docs-release/guides/legacy-migration-agent-prompt.md +384 -0
  19. package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +361 -0
  20. package/docs-release/guides/migration-playbook.en-US.md +325 -0
  21. package/docs-release/guides/migration-playbook.md +329 -0
  22. package/docs-release/guides/parent-control-repository-pattern.en-US.md +252 -0
  23. package/docs-release/guides/parent-control-repository-pattern.md +252 -0
  24. package/docs-release/guides/repository-operating-models.en-US.md +196 -0
  25. package/docs-release/guides/repository-operating-models.md +196 -0
  26. package/docs-release/intl/README.md +15 -0
  27. package/docs-release/intl/de-DE.md +18 -0
  28. package/docs-release/intl/en-US.md +18 -0
  29. package/docs-release/intl/es-ES.md +18 -0
  30. package/docs-release/intl/fr-FR.md +18 -0
  31. package/docs-release/intl/ja-JP.md +18 -0
  32. package/docs-release/intl/ko-KR.md +18 -0
  33. package/docs-release/intl/zh-CN.md +18 -0
  34. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/brief.md +13 -0
  35. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/lesson_candidates.md +24 -0
  36. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/progress.md +1 -1
  37. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/task_plan.md +4 -2
  38. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/{visual_roadmap.md → visual_map.md} +9 -1
  39. package/package.json +3 -1
  40. package/references/agents-md-pattern.md +3 -3
  41. package/references/docs-directory-standard.md +47 -3
  42. package/references/external-source-intake-standard.md +75 -0
  43. package/references/harness-ledger.md +5 -3
  44. package/references/legacy-12-phase-bootstrap.md +41 -0
  45. package/references/lessons-governance.md +23 -6
  46. package/references/planning-loop.md +41 -3
  47. package/references/project-onboarding-audit.md +10 -0
  48. package/references/repo-governance-standard.md +2 -0
  49. package/references/testing-standard.md +50 -0
  50. package/references/walkthrough-closeout.md +6 -5
  51. package/scripts/check-harness.mjs +76 -35
  52. package/scripts/harness.mjs +303 -12
  53. package/scripts/lib/capability-registry.mjs +533 -0
  54. package/scripts/lib/check-profiles.mjs +510 -0
  55. package/scripts/lib/core-shared.mjs +186 -0
  56. package/scripts/lib/dashboard-data.mjs +389 -0
  57. package/scripts/lib/dashboard-workbench.mjs +217 -0
  58. package/scripts/lib/dashboard-writer.mjs +93 -2
  59. package/scripts/lib/harness-core.mjs +10 -1318
  60. package/scripts/lib/lesson-maintenance.mjs +145 -0
  61. package/scripts/lib/markdown-utils.mjs +158 -0
  62. package/scripts/lib/migration-planner.mjs +478 -0
  63. package/scripts/lib/migration-support.mjs +312 -0
  64. package/scripts/lib/task-lifecycle.mjs +755 -0
  65. package/scripts/lib/task-scanner.mjs +682 -0
  66. package/scripts/smoke-dashboard.mjs +22 -0
  67. package/scripts/test-harness.mjs +926 -14
  68. package/templates/AGENTS.md.template +41 -30
  69. package/templates/architecture/Architecture-SSoT.md +21 -0
  70. package/templates/architecture/README.md +49 -0
  71. package/templates/architecture/critical-flows.md +22 -0
  72. package/templates/architecture/local-repo-context.md +20 -0
  73. package/templates/architecture/service-catalog.md +17 -0
  74. package/templates/architecture/services/service-template.md +31 -0
  75. package/templates/architecture/system-map.md +22 -0
  76. package/templates/dashboard/assets/app-src/00-state.js +41 -0
  77. package/templates/dashboard/assets/app-src/10-router.js +76 -0
  78. package/templates/dashboard/assets/app-src/20-overview.js +235 -0
  79. package/templates/dashboard/assets/app-src/30-tasks.js +563 -0
  80. package/templates/dashboard/assets/app-src/40-modules.js +58 -0
  81. package/templates/dashboard/assets/app-src/45-review.js +128 -0
  82. package/templates/dashboard/assets/app-src/50-migration.js +169 -0
  83. package/templates/dashboard/assets/app-src/60-shared.js +61 -0
  84. package/templates/dashboard/assets/app-src/90-bindings.js +382 -0
  85. package/templates/dashboard/assets/app.css +2575 -310
  86. package/templates/dashboard/assets/app.js +1498 -307
  87. package/templates/dashboard/assets/app.manifest.json +11 -0
  88. package/templates/dashboard/assets/i18n.js +429 -44
  89. package/templates/dashboard/assets/mermaid-renderer.js +58 -8
  90. package/templates/development/README.md +52 -0
  91. package/templates/development/codebase-map.md +11 -0
  92. package/templates/development/cross-repo-debugging.md +18 -0
  93. package/templates/development/external-context/service-template.md +33 -0
  94. package/templates/development/external-source-packs/README.md +24 -0
  95. package/templates/development/external-source-packs/digest-template.md +28 -0
  96. package/templates/development/local-setup.md +16 -0
  97. package/templates/development/stubs-and-mocks.md +11 -0
  98. package/templates/integrations/README.md +40 -0
  99. package/templates/integrations/api-contract.md +42 -0
  100. package/templates/integrations/event-contract.md +46 -0
  101. package/templates/integrations/third-party/vendor-template.md +42 -0
  102. package/templates/integrations/webhook-contract.md +41 -0
  103. package/templates/planning/brief.md +32 -0
  104. package/templates/planning/lesson_candidates.md +58 -0
  105. package/templates/planning/long-running-task-contract.md +7 -0
  106. package/templates/planning/module_brief.md +25 -0
  107. package/templates/planning/module_session_prompt.md +6 -0
  108. package/templates/planning/task_plan.md +7 -5
  109. package/templates/planning/{visual_roadmap.md → visual_map.md} +24 -2
  110. package/templates/reference/docs-library-standard.md +31 -0
  111. package/templates/reference/execution-workflow-standard.md +4 -2
  112. package/templates/reference/external-source-intake-standard.md +82 -0
  113. package/templates/reference/harness-ledger-standard.md +1 -0
  114. package/templates/reference/repo-governance-standard.md +6 -4
  115. package/templates/reference/walkthrough-standard.md +2 -1
  116. package/templates/walkthrough/walkthrough-template.md +2 -2
  117. package/templates-zh-CN/AGENTS.md.template +69 -70
  118. package/templates-zh-CN/architecture/Architecture-SSoT.md +21 -0
  119. package/templates-zh-CN/architecture/README.md +51 -0
  120. package/templates-zh-CN/architecture/critical-flows.md +24 -0
  121. package/templates-zh-CN/architecture/local-repo-context.md +20 -0
  122. package/templates-zh-CN/architecture/service-catalog.md +17 -0
  123. package/templates-zh-CN/architecture/services/service-template.md +31 -0
  124. package/templates-zh-CN/architecture/system-map.md +22 -0
  125. package/templates-zh-CN/development/README.md +54 -0
  126. package/templates-zh-CN/development/codebase-map.md +11 -0
  127. package/templates-zh-CN/development/cross-repo-debugging.md +18 -0
  128. package/templates-zh-CN/development/external-context/service-template.md +33 -0
  129. package/templates-zh-CN/development/external-source-packs/README.md +24 -0
  130. package/templates-zh-CN/development/external-source-packs/digest-template.md +28 -0
  131. package/templates-zh-CN/development/local-setup.md +16 -0
  132. package/templates-zh-CN/development/stubs-and-mocks.md +11 -0
  133. package/templates-zh-CN/integrations/README.md +42 -0
  134. package/templates-zh-CN/integrations/api-contract.md +42 -0
  135. package/templates-zh-CN/integrations/event-contract.md +46 -0
  136. package/templates-zh-CN/integrations/third-party/vendor-template.md +42 -0
  137. package/templates-zh-CN/integrations/webhook-contract.md +41 -0
  138. package/templates-zh-CN/planning/brief.md +32 -0
  139. package/templates-zh-CN/planning/lesson_candidates.md +58 -0
  140. package/templates-zh-CN/planning/long-running-task-contract.md +1 -1
  141. package/templates-zh-CN/planning/module_brief.md +25 -0
  142. package/templates-zh-CN/planning/module_plan.md +2 -2
  143. package/templates-zh-CN/planning/module_session_prompt.md +4 -3
  144. package/templates-zh-CN/planning/task_plan.md +10 -4
  145. package/templates-zh-CN/planning/{visual_roadmap.md → visual_map.md} +21 -2
  146. package/templates-zh-CN/reference/docs-library-standard.md +35 -0
  147. package/templates-zh-CN/reference/execution-workflow-standard.md +9 -2
  148. package/templates-zh-CN/reference/external-source-intake-standard.md +82 -0
  149. package/templates-zh-CN/reference/harness-ledger-standard.md +5 -2
  150. package/templates-zh-CN/reference/repo-governance-standard.md +2 -0
  151. package/templates-zh-CN/reference/walkthrough-standard.md +4 -4
  152. package/templates-zh-CN/walkthrough/Closeout-SSoT.md +2 -2
  153. package/templates-zh-CN/walkthrough/walkthrough-template.md +2 -2
  154. package/templates-zh-CN/dashboard/assets/app.css +0 -399
  155. package/templates-zh-CN/dashboard/assets/app.js +0 -435
  156. package/templates-zh-CN/dashboard/assets/i18n.js +0 -47
  157. package/templates-zh-CN/dashboard/assets/markdown-reader.js +0 -116
  158. package/templates-zh-CN/dashboard/assets/mermaid-renderer.js +0 -59
  159. package/templates-zh-CN/dashboard/index.html +0 -18
@@ -3,7 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
 
8
8
  const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
9
9
  const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
@@ -17,6 +17,14 @@ const sampleOpenFindingPattern = /^\|\s*(?:F|R|SR|V|RR|HL)-\d+\s*\|.*\|\s*(?:ope
17
17
  const englishFirstZhHeadingPattern = /^#{1,6}\s+(?:Reviewer Identity|Confidence Challenge|Material Findings|Non-Material Notes|Evidence Checked|Final Confidence Basis|Follow-Up Routing|Phase Graph|Phase Table|Context Packet|Artifact Index|Stop Condition|Pause Conditions|Deliverables|Module Session Prompt|Subagent\s*\/\s*Worker|Coordinator|Worktree|Slice ID|Parent Phase|Inputs|Verifier\b|Harness\b|Closeout\b|Lessons\b)/m;
18
18
  const zhMechanicalEnglishWorkflowPattern = /^\s*\d+\.\s*(?:implement|run locally|self-review|rerun evidence)\b/im;
19
19
  const zhMechanicalEvidencePhrasePattern = /\b(?:local smoke|browser or UI inspection|live environment smoke|reviewer findings|PR checks\s*\/\s*workflow run)\b/i;
20
+ const { taskMigrationClassification, requiresCanonicalVisualMap } = await import("./lib/harness-core.mjs");
21
+ const todayLocal = (() => {
22
+ const now = new Date();
23
+ const y = now.getFullYear();
24
+ const m = String(now.getMonth() + 1).padStart(2, "0");
25
+ const d = String(now.getDate()).padStart(2, "0");
26
+ return `${y}-${m}-${d}`;
27
+ })();
20
28
 
21
29
  function run(args, options = {}) {
22
30
  const result = spawnSync(node, [cli, ...args], {
@@ -42,6 +50,44 @@ function expectJson(args) {
42
50
  return JSON.parse(result.stdout);
43
51
  }
44
52
 
53
+ function waitForWorkbench(child) {
54
+ return new Promise((resolve, reject) => {
55
+ let stdout = "";
56
+ let stderr = "";
57
+ const timer = setTimeout(() => {
58
+ child.kill("SIGTERM");
59
+ reject(new Error(`workbench did not start\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`));
60
+ }, 8000);
61
+ child.stdout.on("data", (chunk) => {
62
+ stdout += chunk.toString();
63
+ const match = stdout.match(/(?:dashboard workbench|harness dev):\s+(http:\/\/127\.0\.0\.1:\d+\/)\s+csrf=([a-f0-9]+)/i);
64
+ if (!match) return;
65
+ clearTimeout(timer);
66
+ resolve({ url: match[1], csrf: match[2], stdout, stderr });
67
+ });
68
+ child.stderr.on("data", (chunk) => {
69
+ stderr += chunk.toString();
70
+ });
71
+ child.on("exit", (code) => {
72
+ if (code !== null && code !== 0) {
73
+ clearTimeout(timer);
74
+ reject(new Error(`workbench exited with ${code}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`));
75
+ }
76
+ });
77
+ });
78
+ }
79
+
80
+ async function waitForCondition(fn, message, { timeout = 8000, interval = 200 } = {}) {
81
+ const started = Date.now();
82
+ let lastValue;
83
+ while (Date.now() - started < timeout) {
84
+ lastValue = await fn();
85
+ if (lastValue) return lastValue;
86
+ await new Promise((resolve) => setTimeout(resolve, interval));
87
+ }
88
+ throw new Error(`${message}: ${JSON.stringify(lastValue)}`);
89
+ }
90
+
45
91
  function commandExists(command) {
46
92
  const result = spawnSync(command, ["-v"], { encoding: "utf8" });
47
93
  return !result.error && result.status === 0;
@@ -85,20 +131,71 @@ function tclWord(value) {
85
131
  return `{${String(value).replace(/\\/g, "\\\\").replace(/}/g, "\\}")}}`;
86
132
  }
87
133
 
134
+ function acceptNoLessonCandidate(taskDir) {
135
+ const candidatePath = path.join(taskDir, "lesson_candidates.md");
136
+ let content = fs.readFileSync(candidatePath, "utf8");
137
+ content = content
138
+ .replace("| Task-level status | pending-review |", "| Task-level status | no-candidate-accepted |")
139
+ .replace("| Review decision | pending-human-review |", "| Review decision | accepted-no-candidate |")
140
+ .replace("| Closeout token | pending |", "| Closeout token | checked-candidate:LC-TEST-000 |")
141
+ .replace(
142
+ "Not decided yet. Fill this only when review accepts that the task produced no reusable lesson candidate.",
143
+ "Human review accepted that this fixture produced no reusable lesson candidate.",
144
+ )
145
+ .replace("尚未判定。只有人工审查接受本任务没有可复用候选时,才填写这里。", "人工审查已接受该测试夹具没有可复用教训候选。");
146
+ fs.writeFileSync(candidatePath, content);
147
+ }
148
+
149
+ const skillContent = fs.readFileSync(path.join(repoRoot, "SKILL.md"), "utf8");
150
+ assert(!skillContent.includes("Historical 12-Phase Bootstrap"), "SKILL.md should not carry the legacy 12-phase reference body");
151
+ assert(
152
+ skillContent.includes("references/legacy-12-phase-bootstrap.md"),
153
+ "SKILL.md should route legacy bootstrap details to the reference document",
154
+ );
155
+ assert(
156
+ fs.readFileSync(path.join(repoRoot, "references/legacy-12-phase-bootstrap.md"), "utf8").includes("Historical 12-Phase Bootstrap"),
157
+ "legacy 12-phase bootstrap reference should exist",
158
+ );
159
+
88
160
  expectPass(["check", "--profile", "source-package", "."]);
89
161
  if (fs.existsSync(path.join(repoRoot, ".harness-private"))) {
90
162
  expectPass(["check", "--profile", "private-harness", ".harness-private"]);
163
+ const privateStatus = expectJson(["status", "--json", ".harness-private"]);
164
+ assert(privateStatus.tasks.length >= 1, "private-harness status JSON should be complete and parseable");
91
165
  }
92
166
 
167
+ const sourceBoundaryTarget = path.join(tmpRoot, "source-boundary-target");
168
+ fs.mkdirSync(path.join(sourceBoundaryTarget, "scripts"), { recursive: true });
169
+ fs.mkdirSync(path.join(sourceBoundaryTarget, "templates/planning"), { recursive: true });
170
+ fs.mkdirSync(path.join(sourceBoundaryTarget, "docs/private"), { recursive: true });
171
+ fs.mkdirSync(path.join(sourceBoundaryTarget, ".harness-private"), { recursive: true });
172
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "package.json"), "{}\n");
173
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "scripts/harness.mjs"), "#!/usr/bin/env node\n");
174
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "scripts/check-harness.mjs"), "#!/usr/bin/env node\n");
175
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "templates/planning/task_plan.md"), "# Task\n");
176
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "AGENTS.md"), "# Local only\n");
177
+ fs.writeFileSync(path.join(sourceBoundaryTarget, "docs/private/plan.md"), "# Private\n");
178
+ fs.writeFileSync(path.join(sourceBoundaryTarget, ".harness-private/AGENTS.md"), "# Private harness\n");
179
+ spawnSync("git", ["init"], { cwd: sourceBoundaryTarget, encoding: "utf8" });
180
+ spawnSync("git", ["add", "-f", "AGENTS.md", "docs/private/plan.md", ".harness-private/AGENTS.md"], { cwd: sourceBoundaryTarget, encoding: "utf8" });
181
+ const sourceBoundaryCheck = run(["check", "--profile", "source-package", sourceBoundaryTarget]);
182
+ assert(sourceBoundaryCheck.status !== 0, "source-package check should reject staged local-only harness files");
183
+ assert(sourceBoundaryCheck.stderr.includes("private local-only file staged: AGENTS.md"), "source-package check should report staged AGENTS.md");
184
+ assert(sourceBoundaryCheck.stderr.includes("private local-only file staged: docs/private/plan.md"), "source-package check should report staged docs/");
185
+ assert(sourceBoundaryCheck.stderr.includes("private local-only file staged: .harness-private/AGENTS.md"), "source-package check should report staged .harness-private/");
186
+
93
187
  const englishTemplateFiles = relativeFiles(path.join(repoRoot, "templates"));
94
188
  const chineseTemplateFiles = relativeFiles(path.join(repoRoot, "templates-zh-CN"));
189
+ const englishNonDashboardTemplateFiles = englishTemplateFiles.filter((file) => !file.startsWith("dashboard/"));
190
+ const chineseNonDashboardTemplateFiles = chineseTemplateFiles.filter((file) => !file.startsWith("dashboard/"));
95
191
  assert(englishTemplateFiles.length > 0, "templates/ should contain English templates");
96
192
  assert(chineseTemplateFiles.length > 0, "templates-zh-CN/ should contain Chinese templates");
97
193
  assert(
98
- JSON.stringify(englishTemplateFiles) === JSON.stringify(chineseTemplateFiles),
99
- "templates/ and templates-zh-CN/ should expose the same template file set",
194
+ JSON.stringify(englishNonDashboardTemplateFiles) === JSON.stringify(chineseNonDashboardTemplateFiles),
195
+ "templates/ and templates-zh-CN/ should expose the same non-dashboard template file set",
100
196
  );
101
- for (const relativeFile of englishTemplateFiles) {
197
+ assert(!chineseTemplateFiles.some((file) => file.startsWith("dashboard/")), "templates-zh-CN/dashboard should be removed; dashboard uses runtime i18n");
198
+ for (const relativeFile of englishNonDashboardTemplateFiles) {
102
199
  const content = fs.readFileSync(path.join(repoRoot, "templates", relativeFile), "utf8");
103
200
  assert(!chineseCharacterPattern.test(content), `English template contains Chinese text: ${relativeFile}`);
104
201
  assert(!brokenMechanicalTemplatePattern.test(content), `English template contains mechanical placeholder text: ${relativeFile}`);
@@ -109,16 +206,25 @@ assert(
109
206
  fs.readFileSync(path.join(repoRoot, "templates-zh-CN", "AGENTS.md.template"), "utf8").includes("项目概况"),
110
207
  "templates-zh-CN should provide Chinese AGENTS.md content",
111
208
  );
112
- for (const relativeFile of chineseTemplateFiles) {
209
+ for (const relativeFile of chineseNonDashboardTemplateFiles) {
113
210
  const content = fs.readFileSync(path.join(repoRoot, "templates-zh-CN", relativeFile), "utf8");
114
211
  assert(!brokenMechanicalTemplatePattern.test(content), `Chinese template contains mechanical placeholder text: ${relativeFile}`);
115
212
  assert(!staleDispositionPattern.test(content), `Chinese template contains stale disposition vocabulary: ${relativeFile}`);
116
213
  assert(!sampleOpenFindingPattern.test(content), `Chinese template contains a real open sample finding row: ${relativeFile}`);
117
214
  assert(!englishFirstZhHeadingPattern.test(content), `Chinese template contains English-first review heading: ${relativeFile}`);
118
215
  assert(!zhMechanicalEnglishWorkflowPattern.test(content), `Chinese template contains unlocalized workflow phrase: ${relativeFile}`);
119
- assert(!zhMechanicalEvidencePhrasePattern.test(content), `Chinese template contains unlocalized evidence phrase: ${relativeFile}`);
216
+ assert(!zhMechanicalEvidencePhrasePattern.test(content), `Chinese template contains unlocalized evidence phrase: ${relativeFile}`);
120
217
  }
121
218
 
219
+ assert(taskMigrationClassification("unknown", "legacy-only") === "unknown-needs-human", "unknown legacy-only task should require human classification");
220
+ assert(taskMigrationClassification("done", "not-needed") === "historical-no-map-needed", "done task with not-needed visual map should not require migration action");
221
+ assert(taskMigrationClassification("active", "present") === "active", "active task with canonical visual map should remain active");
222
+ assert(taskMigrationClassification("reopened", "missing") === "active", "reopened task should be treated as active migration work");
223
+ assert(
224
+ requiresCanonicalVisualMap({ migrationClassification: "historical-no-map-needed" }) === false,
225
+ "historical-no-map-needed should not generate a canonical visual map action",
226
+ );
227
+
122
228
  const exampleStatus = expectJson(["status", "--json", "examples/minimal-project"]);
123
229
  assert(exampleStatus.project.name === "minimal-project", "example status project name mismatch");
124
230
  assert(Array.isArray(exampleStatus.tasks), "example status missing tasks array");
@@ -131,8 +237,11 @@ expectPass(["dashboard", "--out", dashboardPath, "examples/minimal-project"]);
131
237
  assert(fs.existsSync(dashboardPath), "dashboard file was not created");
132
238
  const dashboardHtml = fs.readFileSync(dashboardPath, "utf8");
133
239
  assert(dashboardHtml.includes("Harness Dashboard"), "dashboard HTML missing title");
134
- assert(dashboardHtml.includes("Evidence"), "dashboard HTML missing evidence section");
135
- assert(dashboardHtml.includes("Recent Activity"), "dashboard HTML missing recent activity section");
240
+ assert(dashboardHtml.includes("window.__HARNESS_DASHBOARD__"), "dashboard HTML missing inline data bundle");
241
+ assert(dashboardHtml.includes("Human Visibility Dashboard"), "dashboard HTML missing v2 visibility copy");
242
+ assert(dashboardHtml.includes("#/tasks"), "dashboard HTML missing task index route");
243
+ assert(dashboardHtml.includes("#/review"), "dashboard HTML missing review queue route");
244
+ assert(dashboardHtml.includes("function reviewQueue()"), "dashboard HTML missing review queue page implementation");
136
245
 
137
246
  const dashboardDir = path.join(tmpRoot, "dashboard-folder");
138
247
  expectPass(["dashboard", "--out-dir", dashboardDir, "examples/minimal-project"]);
@@ -156,10 +265,20 @@ const folderIndex = fs.readFileSync(path.join(dashboardDir, "index.html"), "utf8
156
265
  assert(folderIndex.includes("dashboard-data.js"), "dashboard folder index missing embedded data script");
157
266
  assert(folderIndex.includes("rel=\"icon\""), "dashboard index should suppress favicon request");
158
267
  const folderStatus = JSON.parse(fs.readFileSync(path.join(dashboardDir, "data/status.json"), "utf8"));
159
- assert(folderStatus.tasks[0].roadmapSource === "standalone", "folder status should use standalone visual_roadmap.md");
268
+ assert(folderStatus.tasks[0].visualMapSource === "canonical", "folder status should use canonical visual_map.md");
269
+ assert(folderStatus.tasks[0].roadmapSource === "canonical", "folder status should preserve roadmapSource compatibility as canonical");
270
+ assert(folderStatus.schemaVersion === 2, "dashboard folder status should expose schemaVersion 2");
271
+ assert(folderStatus.summary.fullCutoverEligible === true, "minimal project should expose fullCutoverEligible=true");
272
+ assert(folderStatus.summary.legacyVisualOnlyCount === 0, "minimal project should expose zero legacy visual-only tasks");
273
+ assert(folderStatus.summary.weakBriefCount === 0, "minimal project should expose zero weak briefs");
274
+ assert(folderStatus.summary.unknownClassificationCount === 0, "minimal project should expose zero unknown migration classifications");
275
+ assert(folderStatus.summary.missingCanonicalVisualMapCount === 0, "minimal project should expose zero missing canonical visual maps");
160
276
  const documents = JSON.parse(fs.readFileSync(path.join(dashboardDir, "data/documents.json"), "utf8"));
277
+ assert(documents.documents.some((doc) => doc.path.endsWith("/brief.md")), "documents should include task briefs");
278
+ assert(documents.documents.some((doc) => doc.path.endsWith("/task_plan.md")), "documents should include task plan fallback");
161
279
  assert(documents.documents.some((doc) => doc.path.endsWith("execution_strategy.md")), "documents missing execution strategy");
162
- assert(documents.documents.some((doc) => doc.path.endsWith("visual_roadmap.md")), "documents missing visual roadmap");
280
+ assert(documents.documents.some((doc) => doc.path.endsWith("visual_map.md")), "documents missing visual map");
281
+ assert(documents.documents.some((doc) => doc.path.endsWith("lesson_candidates.md")), "documents missing lesson candidates");
163
282
  const tables = JSON.parse(fs.readFileSync(path.join(dashboardDir, "data/tables.json"), "utf8"));
164
283
  assert(tables.tables.some((table) => table.kind === "harness-ledger"), "documents missing harness ledger table");
165
284
  assert(JSON.stringify(tables).includes("alpha|beta"), "markdown table parser should preserve escaped pipes");
@@ -169,10 +288,30 @@ assertGraphIntegrity(graph, "example graph");
169
288
  const dashboardApp = fs.readFileSync(path.join(dashboardDir, "assets/app.js"), "utf8");
170
289
  const dashboardMarkdown = fs.readFileSync(path.join(dashboardDir, "assets/markdown-reader.js"), "utf8");
171
290
  const dashboardMermaid = fs.readFileSync(path.join(dashboardDir, "assets/mermaid-renderer.js"), "utf8");
172
- assert(dashboardApp.includes("data-render-mode"), "dashboard missing render/source toggle");
173
- assert(dashboardApp.includes("escapeHtml(pageTitle())"), "dashboard page title must be escaped");
291
+ assert(dashboardApp.includes("hashchange"), "dashboard should use hash routing");
292
+ assert(dashboardApp.includes("taskDetail("), "dashboard should implement task detail route");
293
+ assert(dashboardApp.includes("data-render-toggle"), "dashboard missing render/source toggle");
294
+ assert(dashboardApp.includes("data-search"), "dashboard missing task search control");
295
+ assert(dashboardApp.includes("taskGroupsPerPage"), "dashboard missing global task group paging");
296
+ assert(dashboardApp.includes("taskStatsBar"), "dashboard missing task stats bar");
297
+ assert(dashboardApp.includes("task-row-card"), "dashboard missing upgraded task row card");
298
+ assert(dashboardApp.includes("fullCutoverEligible"), "dashboard missing full cutover summary field");
299
+ assert(dashboardApp.includes("legacyVisualOnlyCount"), "dashboard missing legacy visual-only summary field");
300
+ assert(dashboardApp.includes("weakBriefCount"), "dashboard missing weak brief summary field");
301
+ assert(dashboardApp.includes("warningQueue()"), "dashboard missing warning queue workbench");
302
+ assert(dashboardApp.includes("reviewWorkspace("), "dashboard missing review workspace route implementation");
303
+ assert(dashboardApp.includes("[\"lessonCandidates\", \"lesson_candidates.md\"]"), "dashboard should expose lesson candidate documents");
304
+ assert(dashboardApp.includes("migrationRunwayBreakdown"), "dashboard missing aggregate migration runway drilldown");
305
+ assert(dashboardApp.includes("[\"brief\", \"brief.md\"]"), "dashboard should make brief.md the first task detail tab");
306
+ assert(dashboardApp.includes("[\"visualMap\", \"visual_map.md\"]"), "dashboard should expose canonical visual_map.md tab");
307
+ assert(dashboardApp.includes("projectMermaid"), "dashboard should render project flow from graph data");
308
+ assert(dashboardApp.includes("escapeHtml(projectName())"), "dashboard project title must be escaped");
174
309
  assert(dashboardMarkdown.includes("rendered-table"), "dashboard missing rendered markdown table support");
175
310
  assert(dashboardMermaid.includes("mermaid-rendered"), "dashboard missing rendered mermaid output");
311
+ const dashboardCss = fs.readFileSync(path.join(dashboardDir, "assets/app.css"), "utf8");
312
+ assert(dashboardCss.includes(".runtime-banner"), "dashboard missing static read-only banner styling");
313
+ assert(dashboardCss.includes("max-height: min(68vh, 620px)"), "dashboard missing mermaid viewport containment");
314
+ assert(dashboardCss.includes(".review-workspace-grid"), "dashboard missing review workspace layout");
176
315
  for (const generated of ["data/status.json", "data/tables.json", "data/documents.json", "data/graph.json", "data/adoption.json", "assets/dashboard-data.js"]) {
177
316
  const content = fs.readFileSync(path.join(dashboardDir, generated), "utf8");
178
317
  assert(!content.includes(repoRoot), `${generated} leaked absolute repo path`);
@@ -187,6 +326,18 @@ const unsafeDocsOut = run(["dashboard", "--out-dir", "examples/minimal-project/d
187
326
  assert(unsafeDocsOut.status !== 0, "dashboard --out-dir target docs should be refused");
188
327
  const unsafeDocsChildOut = run(["dashboard", "--out-dir", "examples/minimal-project/docs/generated-dashboard", "examples/minimal-project"]);
189
328
  assert(unsafeDocsChildOut.status !== 0, "dashboard --out-dir inside target docs should be refused");
329
+ const staticWorkbenchFlagDir = path.join(tmpRoot, "static-workbench-flag");
330
+ expectPass(["dashboard", "--out-dir", staticWorkbenchFlagDir, "examples/minimal-project"]);
331
+ assert(
332
+ fs.readFileSync(path.join(staticWorkbenchFlagDir, "index.html"), "utf8").includes("__HARNESS_WORKBENCH__ = false"),
333
+ "static dashboard folder should not enable workbench runtime",
334
+ );
335
+ assert(
336
+ fs.readFileSync(path.join(staticWorkbenchFlagDir, "assets/app.js"), "utf8").includes("staticReadOnly"),
337
+ "static dashboard app should render a visible read-only runtime boundary",
338
+ );
339
+ const helpOutput = expectPass(["help"]).stdout;
340
+ assert(helpOutput.includes("harness dev"), "help should advertise harness dev as the daily dynamic workbench entry");
190
341
 
191
342
  const redactionTarget = path.join(tmpRoot, "redaction-target");
192
343
  fs.mkdirSync(path.join(redactionTarget, "docs/09-PLANNING/TASKS/path-check"), { recursive: true });
@@ -207,7 +358,11 @@ fs.mkdirSync(dryRunTarget);
207
358
  const dryRun = expectJson(["init", "--dry-run", "--locale", "zh-CN", "--capabilities", "core,dashboard", dryRunTarget]);
208
359
  assert(dryRun.dryRun === true, "init dry-run did not report dryRun true");
209
360
  assert(dryRun.locale === "zh-CN", "init dry-run did not preserve zh-CN locale");
210
- assert(!dryRun.changes.some((change) => change.destination.startsWith("docs/11-REFERENCE/")), "init scaffold should not mechanically copy reference standards");
361
+ assert(dryRun.nextCommands?.some((command) => command.includes("coding-agent-harness dev")), "init output should recommend harness dev as the next human dashboard command");
362
+ assert(
363
+ dryRun.changes.filter((change) => change.destination.startsWith("docs/11-REFERENCE/")).every((change) => change.destination === "docs/11-REFERENCE/external-source-intake-standard.md"),
364
+ "init scaffold should only copy the external source intake standard as a core reference",
365
+ );
211
366
  assert(
212
367
  dryRun.changes.some((change) => change.source === "templates-zh-CN/planning/task_plan.md"),
213
368
  "init zh-CN dry-run should use localized task_plan template when available",
@@ -244,9 +399,15 @@ assert(zhInit.report?.locale === "zh-CN", "init output should include install re
244
399
  assert(zhInit.report?.capabilities?.some((capability) => capability.name === "core" && capability.default === true), "install report should explain core as default");
245
400
  assert(zhInit.report?.capabilities?.some((capability) => capability.name === "dashboard" && capability.selected === true), "install report should mark selected capabilities");
246
401
  assert(zhInit.report?.agentInstructions?.some((item) => item.includes("--locale")), "install report should remind agents to pass --locale explicitly");
402
+ assert(zhInit.nextCommands?.some((command) => command.includes("coding-agent-harness dev")), "init should print a dev workbench next command");
247
403
  const zhRegistry = JSON.parse(fs.readFileSync(path.join(zhInitTarget, ".harness-capabilities.json"), "utf8"));
248
404
  assert(zhRegistry.locale === "zh-CN", "init should persist zh-CN locale");
249
405
  assert(fs.readFileSync(path.join(zhInitTarget, "AGENTS.md"), "utf8").includes("项目概况"), "zh-CN init should write Chinese AGENTS.md");
406
+ assert(fs.existsSync(path.join(zhInitTarget, "docs/11-REFERENCE/external-source-intake-standard.md")), "zh-CN init should create external source intake standard");
407
+ assert(
408
+ fs.readFileSync(path.join(zhInitTarget, "docs/04-DEVELOPMENT/external-source-packs/README.md"), "utf8").includes("外部资料包索引"),
409
+ "zh-CN init should create localized external source pack registry",
410
+ );
250
411
  const zhReviewTemplate = fs.readFileSync(path.join(zhInitTarget, "docs/09-PLANNING/TASKS/_task-template/review.md"), "utf8");
251
412
  assert(zhReviewTemplate.includes("| ID | Severity | Finding | Evidence Checked | Required Action | Open | Disposition | Blocks Release | Follow-up |"), "zh-CN review template should preserve checker table headers");
252
413
  const zhInitCheck = expectJson(["status", "--json", zhInitTarget]);
@@ -256,8 +417,26 @@ const zhDashboardDir = path.join(tmpRoot, "zh-dashboard");
256
417
  expectPass(["dashboard", "--out-dir", zhDashboardDir, zhInitTarget]);
257
418
  const zhDashboardIndex = fs.readFileSync(path.join(zhDashboardDir, "index.html"), "utf8");
258
419
  const zhDashboardApp = fs.readFileSync(path.join(zhDashboardDir, "assets/app.js"), "utf8");
420
+ const zhDashboardI18n = fs.readFileSync(path.join(zhDashboardDir, "assets/i18n.js"), "utf8");
259
421
  assert(zhDashboardIndex.includes("Harness 控制台"), "zh-CN dashboard should use localized index template");
260
- assert(zhDashboardApp.includes("项目驾驶舱"), "zh-CN dashboard should use localized app template");
422
+ assert(zhDashboardApp.includes("projectCockpit"), "zh-CN dashboard should render through localized labels");
423
+ assert(zhDashboardI18n.includes("控制台"), "zh-CN dashboard should include localized app labels");
424
+ assert(zhDashboardApp.includes("data-language-toggle"), "dashboard should expose runtime language toggle");
425
+ assert(zhDashboardIndex.includes("__HARNESS_LOCALE__"), "dashboard should bootstrap locale explicitly");
426
+
427
+ const packageScriptTarget = path.join(tmpRoot, "package-script-target");
428
+ fs.mkdirSync(packageScriptTarget);
429
+ fs.writeFileSync(path.join(packageScriptTarget, "package.json"), JSON.stringify({ scripts: { test: "node --version" } }, null, 2));
430
+ const packageScriptInit = expectJson(["init", "--locale", "en-US", "--capabilities", "core,dashboard", "--add-npm-scripts", packageScriptTarget]);
431
+ assert(packageScriptInit.changes.some((change) => change.destination === "package.json" && change.action === "update-scripts"), "init --add-npm-scripts should report package.json script update");
432
+ const packageScripts = JSON.parse(fs.readFileSync(path.join(packageScriptTarget, "package.json"), "utf8")).scripts;
433
+ assert(packageScripts.test === "node --version", "init --add-npm-scripts should preserve existing scripts");
434
+ assert(packageScripts["harness:dev"] === "coding-agent-harness dev .", "init --add-npm-scripts should add harness:dev");
435
+ assert(packageScripts["harness:dashboard"] === "coding-agent-harness dashboard --out-dir tmp/harness-dashboard .", "init --add-npm-scripts should add static dashboard script");
436
+ const noPackageScriptTarget = path.join(tmpRoot, "no-package-script-target");
437
+ fs.mkdirSync(noPackageScriptTarget);
438
+ const noPackageScripts = run(["init", "--dry-run", "--locale", "en-US", "--capabilities", "core", "--add-npm-scripts", noPackageScriptTarget]);
439
+ assert(noPackageScripts.status !== 0, "init --add-npm-scripts should require an existing package.json");
261
440
 
262
441
  const enRunTarget = path.join(tmpRoot, "en-run-target");
263
442
  fs.mkdirSync(enRunTarget);
@@ -270,10 +449,476 @@ assert(
270
449
  const enInitTarget = path.join(tmpRoot, "en-init-target");
271
450
  fs.mkdirSync(enInitTarget);
272
451
  expectJson(["init", "--locale", "en-US", "--capabilities", "core,dashboard", enInitTarget]);
452
+ assert(
453
+ fs.readFileSync(path.join(enInitTarget, "docs/11-REFERENCE/external-source-intake-standard.md"), "utf8").includes("External Source Intake Standard"),
454
+ "en-US init should create English external source intake standard",
455
+ );
456
+ assert(
457
+ fs.readFileSync(path.join(enInitTarget, "docs/04-DEVELOPMENT/external-source-packs/README.md"), "utf8").includes("External Source Packs"),
458
+ "en-US init should create English external source pack registry",
459
+ );
273
460
  const enInitStatus = expectJson(["status", "--json", enInitTarget]);
274
461
  assert(enInitStatus.checkState.status === "pass", "en-US core+dashboard init should pass status check");
275
462
  assert(enInitStatus.checkState.warnings === 0, "en-US core+dashboard init should not warn about safe-adoption");
276
463
 
464
+ const lifecycleTarget = path.join(tmpRoot, "lifecycle-target");
465
+ fs.mkdirSync(lifecycleTarget);
466
+ expectJson(["init", "--locale", "zh-CN", "--capabilities", "core,dashboard", lifecycleTarget]);
467
+ const lifecycleDryRun = expectJson(["new-task", "phase-2-lifecycle", "--title", "阶段二任务生命周期", "--locale", "zh-CN", "--dry-run", lifecycleTarget]);
468
+ assert(lifecycleDryRun.dryRun === true, "new-task dry-run should report dryRun true");
469
+ assert(
470
+ lifecycleDryRun.changes.some((change) => change.destination.endsWith("brief.md") && change.action === "would-create"),
471
+ "new-task dry-run should plan brief.md",
472
+ );
473
+ assert(!fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle`)), "new-task dry-run should not mutate target");
474
+ const lifecycleCreate = expectJson(["new-task", "phase-2-lifecycle", "--title", "阶段二任务生命周期", "--locale", "zh-CN", lifecycleTarget]);
475
+ assert(lifecycleCreate.task?.shortId === `${todayLocal}-phase-2-lifecycle`, "new-task should report normalized short task id");
476
+ assert(lifecycleCreate.task?.id === `TASKS/${todayLocal}-phase-2-lifecycle`, "new-task should report relative task id");
477
+ for (const required of ["brief.md", "task_plan.md", "execution_strategy.md", "visual_map.md", "findings.md", "lesson_candidates.md", "progress.md", "review.md"]) {
478
+ assert(
479
+ fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle`, required)),
480
+ `new-task should create ${required}`,
481
+ );
482
+ }
483
+ assert(
484
+ fs.readFileSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/brief.md`), "utf8").includes("阶段二任务生命周期"),
485
+ "new-task should render the requested title into brief.md",
486
+ );
487
+ assert(
488
+ fs.readFileSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/task_plan.md`), "utf8").includes("Task Contract: harness-task/v1"),
489
+ "new-task should render the durable task contract marker",
490
+ );
491
+ const duplicateLifecycle = run(["new-task", `${todayLocal}-phase-2-lifecycle`, "--title", "duplicate", lifecycleTarget]);
492
+ assert(duplicateLifecycle.status !== 0, "new-task should refuse to overwrite an existing task directory");
493
+ const simpleLifecycle = expectJson(["new-task", "simple-lifecycle", "--budget", "simple", "--title", "简单任务", "--locale", "zh-CN", lifecycleTarget]);
494
+ assert(simpleLifecycle.task?.budget === "simple", "new-task --budget simple should report simple budget");
495
+ for (const required of ["brief.md", "task_plan.md", "visual_map.md", "progress.md"]) {
496
+ assert(
497
+ fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-simple-lifecycle`, required)),
498
+ `simple task should create ${required}`,
499
+ );
500
+ }
501
+ for (const omitted of ["execution_strategy.md", "findings.md", "review.md", "lesson_candidates.md"]) {
502
+ assert(
503
+ !fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-simple-lifecycle`, omitted)),
504
+ `simple task should not create ${omitted}`,
505
+ );
506
+ }
507
+ const simpleTaskPlan = fs.readFileSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-simple-lifecycle/task_plan.md`), "utf8");
508
+ assert(/Selected budget\s*:\s*simple/i.test(simpleTaskPlan) || /选择预算\s*[::]\s*simple/i.test(simpleTaskPlan), "simple task should persist selected budget");
509
+ const longRunningLifecycle = expectJson(["new-task", "long-running-lifecycle", "--long-running", "--title", "长程任务", "--locale", "zh-CN", lifecycleTarget]);
510
+ assert(longRunningLifecycle.task?.longRunning === true, "new-task --long-running should report longRunning true");
511
+ assert(
512
+ fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-long-running-lifecycle/long-running-task-contract.md`)),
513
+ "new-task --long-running should create long-running-task-contract.md",
514
+ );
515
+ const legacyPresetSessionDir = path.join(tmpRoot, "legacy-preset-session");
516
+ fs.mkdirSync(path.join(legacyPresetSessionDir, "dashboard"), { recursive: true });
517
+ fs.writeFileSync(path.join(legacyPresetSessionDir, "dashboard/index.html"), "<html>legacy migration dashboard</html>\n");
518
+ fs.writeFileSync(path.join(legacyPresetSessionDir, "migrate-plan.json"), JSON.stringify({ operation: "migrate-plan", summary: { warnings: 2, legacyResiduals: 1 } }, null, 2));
519
+ const legacyPresetSessionPath = path.join(legacyPresetSessionDir, "session.json");
520
+ fs.writeFileSync(
521
+ legacyPresetSessionPath,
522
+ JSON.stringify(
523
+ {
524
+ operation: "migrate-run",
525
+ schemaVersion: 1,
526
+ generatedAt: "2026-05-22T00:00:00.000Z",
527
+ result: "adopted-with-strict-deferred",
528
+ target: lifecycleTarget,
529
+ sessionDir: legacyPresetSessionDir,
530
+ planOnly: false,
531
+ dashboard: { dir: path.join(legacyPresetSessionDir, "dashboard"), indexPath: path.join(legacyPresetSessionDir, "dashboard/index.html"), kind: "html-folder" },
532
+ plan: {
533
+ mode: "legacy-compat",
534
+ summary: {
535
+ warnings: 2,
536
+ visualMapActions: 0,
537
+ legacyVisualOnly: 0,
538
+ unknownClassification: 0,
539
+ weakBrief: 0,
540
+ missingCanonicalVisualMap: 0,
541
+ taskActions: 0,
542
+ reviewSchemaGaps: 0,
543
+ legacyReferenceGaps: 0,
544
+ legacyResiduals: 1,
545
+ fullCutoverEligible: false,
546
+ recommendedCapabilities: ["safe-adoption"],
547
+ },
548
+ },
549
+ checks: {
550
+ normal: { status: "warn", failures: 0, warnings: 2, failureDetails: [], warningDetails: ["legacy residual"] },
551
+ strict: { status: "fail", failures: 1, warnings: 2, failureDetails: ["strict residual"], warningDetails: [] },
552
+ },
553
+ strictDeferred: {
554
+ owner: "migration-owner",
555
+ trigger: "strict-cutover",
556
+ nextAction: "Assign real owner before full cutover.",
557
+ reason: "Historical residual remains.",
558
+ failureCount: 1,
559
+ failures: ["strict residual"],
560
+ },
561
+ git: {
562
+ before: { inGit: false, branch: "", entries: [], staged: [], dirty: false },
563
+ after: { inGit: false, branch: "", entries: [], staged: [], dirty: false },
564
+ },
565
+ },
566
+ null,
567
+ 2,
568
+ ),
569
+ );
570
+ const legacyPresetDryRun = expectJson(["new-task", "--budget", "complex", "--preset", "legacy-migration", "--from-session", legacyPresetSessionPath, "--dry-run"]);
571
+ assert(legacyPresetDryRun.task?.preset === "legacy-migration", "new-task legacy-migration dry-run should report preset");
572
+ assert(!fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-harness-v1-migration`)), "legacy-migration dry-run should not mutate target");
573
+ const legacyPresetTask = expectJson(["new-task", "--budget", "complex", "--preset", "legacy-migration", "--from-session", legacyPresetSessionPath]);
574
+ assert(legacyPresetTask.task?.id === `TASKS/${todayLocal}-harness-v1-migration`, "legacy-migration preset should derive a default task id");
575
+ assert(legacyPresetTask.task?.kind === "project-migration", "legacy-migration preset should report project-migration kind");
576
+ assert(legacyPresetTask.task?.preset === "legacy-migration", "legacy-migration preset should report preset");
577
+ assert(legacyPresetTask.task?.evidenceBundle, "legacy-migration preset should report evidence bundle");
578
+ const legacyPresetTaskDir = path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-harness-v1-migration`);
579
+ const legacyPresetTaskPlan = fs.readFileSync(path.join(legacyPresetTaskDir, "task_plan.md"), "utf8");
580
+ assert(legacyPresetTaskPlan.includes("Task Preset: legacy-migration"), "legacy-migration task plan should persist preset metadata");
581
+ assert(legacyPresetTaskPlan.includes("Migration Achieved Level: migration-deferred"), "strict-deferred session should start as migration-deferred");
582
+ for (const required of ["session.json", "migrate-plan.json", "normal-check.json", "strict-check.json", "migrate-verify.json", "dashboard.hash.txt", "target-git-status.txt", "target-commit.txt", "harness-version.txt", "generated-at.txt"]) {
583
+ assert(
584
+ fs.existsSync(path.join(lifecycleTarget, legacyPresetTask.task.evidenceBundle, required)),
585
+ `legacy-migration preset should copy evidence file ${required}`,
586
+ );
587
+ }
588
+ const legacyPresetStatus = expectJson(["status", "--json", lifecycleTarget]);
589
+ const legacyPresetStatusTask = legacyPresetStatus.tasks.find((task) => task.id === `TASKS/${todayLocal}-harness-v1-migration`);
590
+ assert(legacyPresetStatusTask?.taskKind === "project-migration", "status should expose taskKind");
591
+ assert(legacyPresetStatusTask?.taskPreset === "legacy-migration", "status should expose taskPreset");
592
+ assert(legacyPresetStatusTask?.migrationSnapshot?.strictDeferred === true, "status should expose migration snapshot strictDeferred");
593
+ const legacyPresetDashboardDir = path.join(tmpRoot, "legacy-preset-dashboard");
594
+ expectPass(["dashboard", "--out-dir", legacyPresetDashboardDir, lifecycleTarget]);
595
+ const legacyPresetDashboardData = fs.readFileSync(path.join(legacyPresetDashboardDir, "assets/dashboard-data.js"), "utf8");
596
+ assert(legacyPresetDashboardData.includes("migrationSnapshot"), "dashboard bundle should expose migrationSnapshot");
597
+ fs.writeFileSync(
598
+ path.join(legacyPresetTaskDir, "task_plan.md"),
599
+ legacyPresetTaskPlan.replace("Migration Achieved Level: migration-deferred", "Migration Achieved Level: migration-full-cutover"),
600
+ );
601
+ const falseFullCutoverCheck = run(["check", "--profile", "target-project", lifecycleTarget]);
602
+ assert(falseFullCutoverCheck.status !== 0, "check should reject migration-full-cutover when evidence still has residuals");
603
+ assert(falseFullCutoverCheck.stderr.includes("migration-full-cutover"), "full-cutover preset failure should explain achieved level");
604
+ fs.writeFileSync(path.join(legacyPresetTaskDir, "task_plan.md"), legacyPresetTaskPlan);
605
+ const promotableCandidatePath = path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-long-running-lifecycle/lesson_candidates.md`);
606
+ fs.writeFileSync(
607
+ promotableCandidatePath,
608
+ fs.readFileSync(promotableCandidatePath, "utf8")
609
+ .replace("| Task-level status | pending-review |", "| Task-level status | needs-promotion |")
610
+ .replace("| Review decision | pending-human-review |", "| Review decision | accepted-for-promotion |")
611
+ .replace("| Promotion state | not-promoted |", "| Promotion state | queued |")
612
+ .replace("| Closeout token | pending |", "| Closeout token | queued-promotion:LC-20260521-001 |")
613
+ .replace(
614
+ "| --- | --- | --- | --- | --- | --- |",
615
+ "| --- | --- | --- | --- | --- | --- |\n| LC-20260521-001 | needs-promotion | Commit contract must be explicit | Agents forget proactive commits when contracts are implicit | accepted-for-promotion | references/execution-workflow-standard.md |",
616
+ ),
617
+ );
618
+ const promoteDryRun = expectJson(["lesson-promote", "long-running-lifecycle", "LC-20260521-001", "--dry-run", lifecycleTarget]);
619
+ assert(promoteDryRun.dryRun === true && promoteDryRun.lessonId === "L-2026-05-21-001", "lesson-promote --dry-run should derive the lesson id");
620
+ const promoteRun = expectJson(["lesson-promote", "long-running-lifecycle", "LC-20260521-001", lifecycleTarget]);
621
+ assert(promoteRun.lessonId === "L-2026-05-21-001", "lesson-promote should return the created lesson id");
622
+ assert(
623
+ fs.existsSync(path.join(lifecycleTarget, "docs/01-GOVERNANCE/lessons/L-2026-05-21-001-commit-contract-must-be-explicit.md")),
624
+ "lesson-promote should create a detail document",
625
+ );
626
+ assert(
627
+ fs.readFileSync(path.join(lifecycleTarget, "docs/01-GOVERNANCE/Lessons-SSoT.md"), "utf8").includes("L-2026-05-21-001"),
628
+ "lesson-promote should append Lessons SSoT",
629
+ );
630
+ assert(fs.readFileSync(promotableCandidatePath, "utf8").includes("| LC-20260521-001 | promoted |"), "lesson-promote should mark the candidate row promoted");
631
+ const promoteAgain = expectJson(["lesson-promote", "long-running-lifecycle", "LC-20260521-001", lifecycleTarget]);
632
+ assert(promoteAgain.changes.length === 0, "lesson-promote should be idempotent after promotion");
633
+ expectPass(["check", "--profile", "target-project", lifecycleTarget]);
634
+ expectJson(["task-start", "simple-lifecycle", "--message", "开始简单任务", lifecycleTarget]);
635
+ const simpleComplete = expectJson(["task-complete", "simple-lifecycle", "--message", "简单任务完成", lifecycleTarget]);
636
+ assert(simpleComplete.task?.state === "done", "simple task should be able to complete without review");
637
+ const earlyReview = run(["task-review", "review-too-early", lifecycleTarget]);
638
+ assert(earlyReview.status !== 0, "task-review should reject unknown tasks");
639
+ const tooEarlyTask = expectJson(["new-task", "review-too-early", "--title", "Too early review", "--locale", "zh-CN", lifecycleTarget]);
640
+ assert(tooEarlyTask.task?.id === `TASKS/${todayLocal}-review-too-early`, "new-task should create review-too-early fixture");
641
+ const tooEarlyReview = run(["task-review", "review-too-early", "--message", "too early", lifecycleTarget]);
642
+ assert(tooEarlyReview.status !== 0, "task-review should reject tasks that are not in_progress");
643
+ assert(tooEarlyReview.stderr.includes("in_progress"), "task-review invalid transition should explain required state");
644
+ expectJson(["task-start", "phase-2-lifecycle", "--message", "开始实现生命周期切片", lifecycleTarget]);
645
+ expectJson(["task-log", "phase-2-lifecycle", "--message", "补齐 CLI 与模板", "--evidence", "command:TARGET:npm-test:passed", lifecycleTarget]);
646
+ const noPhaseProgressReview = run(["task-review", "phase-2-lifecycle", "--message", "阶段表尚未更新", lifecycleTarget]);
647
+ assert(noPhaseProgressReview.status !== 0, "task-review should reject standard tasks whose visual map has no recorded phase progress");
648
+ assert(
649
+ noPhaseProgressReview.stderr.includes("task-phase"),
650
+ "task-review phase-progress failure should tell the agent to run task-phase",
651
+ );
652
+ const lifecycleBlocked = expectJson(["task-block", "phase-2-lifecycle", "--message", "等待旧项目迁移验证", lifecycleTarget]);
653
+ assert(lifecycleBlocked.task?.state === "blocked", "task-block should report blocked state");
654
+ const lifecyclePhase = expectJson(["task-phase", "phase-2-lifecycle", "PH-01", "--state", "done", "--completion", "100", "--evidence", "present", lifecycleTarget]);
655
+ assert(lifecyclePhase.task?.phases?.some((phase) => phase.id === "PH-01" && phase.state === "done" && phase.completion === 100), "task-phase should update visual map row");
656
+ assert(
657
+ fs.readFileSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/visual_map.md`), "utf8").includes("Visual Map Contract: v1.0"),
658
+ "new-task should render canonical visual map contract",
659
+ );
660
+ expectJson(["task-phase", "phase-2-lifecycle", "PH-01", "--state", "done", "--completion", "100", "--evidence", "present", lifecycleTarget]);
661
+ const missingPhase = run(["task-phase", "phase-2-lifecycle", "NO_SUCH_PHASE", "--state", "done", lifecycleTarget]);
662
+ assert(missingPhase.status !== 0, "task-phase should fail for unknown phase id");
663
+ assert(missingPhase.stderr.includes("Phase not found"), "task-phase unknown phase should explain missing phase");
664
+ const directComplete = run(["task-complete", "phase-2-lifecycle", "--message", "跳过审查完成", lifecycleTarget]);
665
+ assert(directComplete.status !== 0, "standard task-complete should require review state");
666
+ assert(directComplete.stderr.includes("task-review"), "standard task-complete failure should tell the user to run task-review first");
667
+ expectJson(["task-start", "phase-2-lifecycle", "--message", "恢复执行生命周期切片", lifecycleTarget]);
668
+ const lifecycleReview = expectJson(["task-review", "phase-2-lifecycle", "--message", "进入执行审查", lifecycleTarget]);
669
+ assert(lifecycleReview.task?.state === "review", "task-review should report review state");
670
+ const lifecycleReviewPath = path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/review.md`);
671
+ fs.writeFileSync(
672
+ lifecycleReviewPath,
673
+ fs.readFileSync(lifecycleReviewPath, "utf8").replace(
674
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
675
+ `| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| RR-001 | P1 | Human review is still pending | TARGET:docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/progress.md | confirm in dashboard | yes | open | yes | dashboard |`,
676
+ ),
677
+ );
678
+ const blockedComplete = run(["task-complete", "phase-2-lifecycle", "--message", "带阻塞审查项完成", lifecycleTarget]);
679
+ assert(blockedComplete.status !== 0, "task-complete should reject open blocking review findings");
680
+ assert(blockedComplete.stderr.includes("Open blocking review findings"), "task-complete blocked review failure should explain open findings");
681
+ const blockedConfirm = run(["review-confirm", `TASKS/${todayLocal}-phase-2-lifecycle`, "--reviewer", "Human Reviewer", "--confirm", `${todayLocal}-phase-2-lifecycle`, lifecycleTarget]);
682
+ assert(blockedConfirm.status !== 0, "review-confirm should reject tasks with open blocking review findings");
683
+ assert(blockedConfirm.stderr.includes("Open blocking review findings"), "review-confirm blocked failure should explain open findings");
684
+ fs.writeFileSync(
685
+ lifecycleReviewPath,
686
+ fs.readFileSync(lifecycleReviewPath, "utf8").replace(`| RR-001 | P1 | Human review is still pending | TARGET:docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/progress.md | confirm in dashboard | yes | open | yes | dashboard |`, `| RR-001 | P1 | Human review is closed | TARGET:docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/progress.md | confirmed in dashboard | no | closed | no | none |`),
687
+ );
688
+ const unconfirmedComplete = run(["task-complete", "phase-2-lifecycle", "--message", "未确认审查完成", lifecycleTarget]);
689
+ assert(unconfirmedComplete.status !== 0, "task-complete should require human review confirmation");
690
+ assert(unconfirmedComplete.stderr.includes("review-confirm"), "unconfirmed review failure should tell the user to run review-confirm");
691
+ const missingWalkthroughConfirm = run(["review-confirm", `TASKS/${todayLocal}-phase-2-lifecycle`, "--reviewer", "Human Reviewer", "--message", "walkthrough reviewed", "--confirm", `${todayLocal}-phase-2-lifecycle`, lifecycleTarget]);
692
+ assert(missingWalkthroughConfirm.status !== 0, "review-confirm should require a walkthrough before human confirmation");
693
+ assert(missingWalkthroughConfirm.stderr.includes("walkthrough"), "missing walkthrough confirmation failure should explain the walkthrough requirement");
694
+ const lifecycleWalkthrough = path.join(lifecycleTarget, `docs/10-WALKTHROUGH/${todayLocal}-phase-2-lifecycle-walkthrough.md`);
695
+ fs.writeFileSync(
696
+ lifecycleWalkthrough,
697
+ "# Walkthrough: Phase 2 lifecycle\n\n## Summary\n\nHuman-readable walkthrough for review before completion.\n",
698
+ );
699
+ fs.appendFileSync(
700
+ path.join(lifecycleTarget, "docs/10-WALKTHROUGH/Closeout-SSoT.md"),
701
+ `\n| CL-PHASE-2-LIFECYCLE | 2026-05-21 | Phase 2 lifecycle | \`docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/task_plan.md\` | \`docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/review.md\` | \`docs/10-WALKTHROUGH/${todayLocal}-phase-2-lifecycle-walkthrough.md\` | pending human review | none | checked-none | pending |\n`,
702
+ );
703
+ acceptNoLessonCandidate(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle`));
704
+ const preCompleteStatus = expectJson(["status", "--json", lifecycleTarget]);
705
+ const preCompleteTask = preCompleteStatus.tasks.find((task) => task.id === `TASKS/${todayLocal}-phase-2-lifecycle`);
706
+ assert(preCompleteTask?.walkthroughPath?.endsWith(`docs/10-WALKTHROUGH/${todayLocal}-phase-2-lifecycle-walkthrough.md`), "status should expose walkthrough before human review confirmation");
707
+ const preCompleteConfirm = expectJson(["review-confirm", `TASKS/${todayLocal}-phase-2-lifecycle`, "--reviewer", "Human Reviewer", "--message", "walkthrough reviewed", "--confirm", `${todayLocal}-phase-2-lifecycle`, lifecycleTarget]);
708
+ assert(preCompleteConfirm.task?.reviewStatus === "confirmed", "review-confirm should confirm review before task-complete");
709
+ const lifecycleComplete = expectJson(["task-complete", "phase-2-lifecycle", "--message", "生命周期闭环完成", lifecycleTarget]);
710
+ assert(lifecycleComplete.task?.state === "done", "task-complete should report done state");
711
+ const lifecycleTasks = expectJson(["task-list", "--json", lifecycleTarget]);
712
+ assert(lifecycleTasks.tasks.some((task) => task.id === `TASKS/${todayLocal}-phase-2-lifecycle` && task.state === "done"), "task-list should include completed task");
713
+ assert(lifecycleTasks.tasks.some((task) => task.id === `TASKS/${todayLocal}-simple-lifecycle` && task.budget === "simple"), "task-list should expose parsed task budget");
714
+ const doneLifecycleTasks = expectJson(["task-list", "--json", "--state", "done", lifecycleTarget]);
715
+ assert(doneLifecycleTasks.tasks.every((task) => task.state === "done"), "task-list --state should filter states");
716
+ const lifecycleStatus = expectJson(["status", "--json", lifecycleTarget]);
717
+ assert(lifecycleStatus.schemaVersion === 2, "status should expose dashboard schemaVersion 2");
718
+ const lifecycleTask = lifecycleStatus.tasks.find((task) => task.id === `TASKS/${todayLocal}-phase-2-lifecycle`);
719
+ assert(lifecycleTask?.briefSource === "standalone", "status should expose standalone task brief");
720
+ assert(lifecycleTask?.briefPath?.endsWith("/brief.md"), "status should expose the task brief path");
721
+ assert(lifecycleTask?.classificationBucket === "current", "new v1 tasks should not be classified as legacy");
722
+ assert(lifecycleStatus.summary?.briefCoverage?.missing === 0, "status should expose explicit brief coverage summary");
723
+ assert(lifecycleTask?.state === "done", "status should read lifecycle task state from progress.md");
724
+ assert(lifecycleTask?.lifecycleState === "closing", "done task with pending closeout should remain in closing lifecycle state");
725
+ assert(lifecycleTask?.evidence?.some((item) => item.summary.includes("passed")), "status should collect task-log evidence");
726
+ const confirmedStatus = expectJson(["status", "--json", lifecycleTarget]);
727
+ const confirmedTask = confirmedStatus.tasks.find((task) => task.id === `TASKS/${todayLocal}-phase-2-lifecycle`);
728
+ assert(confirmedTask?.reviewStatus === "confirmed", "status should expose confirmed review status");
729
+ assert(confirmedTask?.closeoutStatus === "pending", "status should keep pending closeout separate from review confirmation");
730
+ assert(fs.readFileSync(lifecycleReviewPath, "utf8").includes("Human Review Confirmation"), "review-confirm should write a human review confirmation block");
731
+ assert(fs.readFileSync(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/progress.md`), "utf8").includes("review-confirm"), "review-confirm should append a progress log entry");
732
+ const moduleLifecycle = expectJson(["new-task", "module-lifecycle", "--module", "auth", "--budget", "complex", "--title", "模块生命周期", "--locale", "zh-CN", lifecycleTarget]);
733
+ assert(moduleLifecycle.task?.id === `MODULES/auth/${todayLocal}-module-lifecycle`, "new-task --module should create a module task id");
734
+ assert(fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle/references/INDEX.md`)), "complex module task should create references index");
735
+ assert(fs.existsSync(path.join(lifecycleTarget, `docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle/artifacts/INDEX.md`)), "complex module task should create artifacts index");
736
+ assert(fs.existsSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/brief.md")), "new-task --module should create a module brief when missing");
737
+ assert(fs.existsSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/module_plan.md")), "new-task --module should create a module plan when missing");
738
+ assert(fs.existsSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/execution_strategy.md")), "new-task --module should create module-level execution strategy when missing");
739
+ assert(fs.existsSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/visual_map.md")), "new-task --module should create module-level visual map when missing");
740
+ assert(fs.existsSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/session_prompt.md")), "new-task --module should create a module session prompt when missing");
741
+ fs.writeFileSync(
742
+ path.join(lifecycleTarget, "docs/09-PLANNING/Module-Registry.md"),
743
+ "# Module Registry\n\n## Active Modules\n\n| ID | Module | Path Scope | Owner | Status | Branch or Worktree | Task Plan | Shared Files | Depends On | Handoff Evidence | Residual | Updated |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| M-AUTH | Auth | src/auth/** | coordinator | reserved | n/a | docs/09-PLANNING/MODULES/auth/module_plan.md | none | none | pending | none | 2026-05-19 |\n",
744
+ );
745
+ fs.writeFileSync(
746
+ path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/module_plan.md"),
747
+ `# Auth Module Plan\n\n## Steps\n\n| Step ID | Name | Status | Task Plan | Depends On |\n| --- | --- | --- | --- | --- |\n| AUTH-01 | Setup | planned | docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle/task_plan.md | none |\n`,
748
+ );
749
+ const moduleStep = expectJson(["module-step", "auth", "AUTH-01", "--state", "done", lifecycleTarget]);
750
+ assert(moduleStep.moduleKey === "auth" && moduleStep.stepId === "AUTH-01", "module-step should report updated module step");
751
+ assert(fs.readFileSync(path.join(lifecycleTarget, "docs/09-PLANNING/MODULES/auth/module_plan.md"), "utf8").includes("| AUTH-01 | Setup | done |"), "module-step should update module_plan status");
752
+ assert(fs.readFileSync(path.join(lifecycleTarget, "docs/09-PLANNING/Module-Registry.md"), "utf8").includes("| M-AUTH | Auth | src/auth/** | coordinator | merged |"), "module-step should update module registry status when done");
753
+ expectJson(["module-step", "auth", "AUTH-01", "--state", "done", lifecycleTarget]);
754
+ const missingModuleStep = run(["module-step", "auth", "NO_SUCH_STEP", "--state", "done", lifecycleTarget]);
755
+ assert(missingModuleStep.status !== 0, "module-step should fail for unknown step id");
756
+ assert(missingModuleStep.stderr.includes("Module step not found"), "module-step unknown step should explain missing step");
757
+ expectJson(["task-start", `MODULES/auth/${todayLocal}-module-lifecycle`, "--message", "开始模块任务审查夹具", lifecycleTarget]);
758
+ expectJson(["task-phase", `MODULES/auth/${todayLocal}-module-lifecycle`, "PH-01", "--state", "done", "--completion", "100", "--evidence", "present", lifecycleTarget]);
759
+ expectJson(["task-review", `MODULES/auth/${todayLocal}-module-lifecycle`, "--message", "模块任务进入审查", lifecycleTarget]);
760
+ const moduleWalkthrough = path.join(lifecycleTarget, `docs/10-WALKTHROUGH/${todayLocal}-module-lifecycle-walkthrough.md`);
761
+ fs.writeFileSync(
762
+ moduleWalkthrough,
763
+ "# Walkthrough: Module lifecycle\n\n## Summary\n\nHuman-readable module walkthrough for review confirmation.\n",
764
+ );
765
+ fs.appendFileSync(
766
+ path.join(lifecycleTarget, "docs/10-WALKTHROUGH/Closeout-SSoT.md"),
767
+ `\n| CL-MODULE-LIFECYCLE | 2026-05-21 | Module lifecycle | \`docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle/task_plan.md\` | \`docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle/review.md\` | \`docs/10-WALKTHROUGH/${todayLocal}-module-lifecycle-walkthrough.md\` | pending human review | none | checked-none | pending |\n`,
768
+ );
769
+ acceptNoLessonCandidate(path.join(lifecycleTarget, `docs/09-PLANNING/MODULES/auth/${todayLocal}-module-lifecycle`));
770
+ const moduleConfirm = expectJson(["review-confirm", `MODULES/auth/${todayLocal}-module-lifecycle`, "--reviewer", "Human Reviewer", "--confirm", `${todayLocal}-module-lifecycle`, lifecycleTarget]);
771
+ assert(moduleConfirm.task?.id === `MODULES/auth/${todayLocal}-module-lifecycle`, "review-confirm should accept full module task ids");
772
+ const workbenchReviewTask = expectJson(["new-task", "workbench-review", "--title", "Workbench review gate", "--locale", "zh-CN", lifecycleTarget]);
773
+ assert(workbenchReviewTask.task?.id === `TASKS/${todayLocal}-workbench-review`, "new-task should create workbench review gate fixture");
774
+ const workbenchReviewProgress = path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-workbench-review/progress.md`);
775
+ const workbenchClosedReviewTask = expectJson(["new-task", "workbench-closed-review", "--title", "Closed review debt", "--locale", "zh-CN", lifecycleTarget]);
776
+ assert(workbenchClosedReviewTask.task?.id === `TASKS/${todayLocal}-workbench-closed-review`, "new-task should create closed review debt fixture");
777
+ const workbenchClosedReviewProgress = path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-workbench-closed-review/progress.md`);
778
+ fs.writeFileSync(
779
+ workbenchClosedReviewProgress,
780
+ fs.readFileSync(workbenchClosedReviewProgress, "utf8").replace(/^## 状态:.*$/m, "## 状态:done"),
781
+ );
782
+ const closedReviewWalkthrough = path.join(lifecycleTarget, "docs/10-WALKTHROUGH/workbench-closed-walkthrough.md");
783
+ fs.writeFileSync(
784
+ closedReviewWalkthrough,
785
+ "# Walkthrough: Closed review debt\n\n## Summary\n\nHuman-readable closeout walkthrough for dashboard review.\n",
786
+ );
787
+ fs.appendFileSync(
788
+ path.join(lifecycleTarget, "docs/10-WALKTHROUGH/Closeout-SSoT.md"),
789
+ `\n| CL-WORKBENCH-CLOSED | 2026-05-21 | Closed review debt | \`docs/09-PLANNING/TASKS/${todayLocal}-workbench-closed-review/task_plan.md\` | \`docs/09-PLANNING/TASKS/${todayLocal}-workbench-closed-review/review.md\` | \`docs/10-WALKTHROUGH/workbench-closed-walkthrough.md\` | test evidence | none | checked-none | closed |\n`,
790
+ );
791
+ const closedReviewStatus = expectJson(["status", "--json", lifecycleTarget]);
792
+ const closedReviewTask = closedReviewStatus.tasks.find((task) => task.id === `TASKS/${todayLocal}-workbench-closed-review`);
793
+ assert(closedReviewTask?.walkthroughPath?.endsWith("docs/10-WALKTHROUGH/workbench-closed-walkthrough.md"), "status should expose task walkthrough path from Closeout SSoT");
794
+ const workbenchDir = path.join(tmpRoot, "review-workbench");
795
+ const workbench = spawn(node, [cli, "dashboard", "--workbench", "--out-dir", workbenchDir, "--host", "127.0.0.1", "--port", "0", lifecycleTarget], {
796
+ cwd: repoRoot,
797
+ stdio: ["ignore", "pipe", "pipe"],
798
+ });
799
+ const runtime = await waitForWorkbench(workbench);
800
+ try {
801
+ const runtimeResponse = await fetch(new URL("api/runtime", runtime.url));
802
+ assert(runtimeResponse.status === 200, "workbench should expose runtime metadata");
803
+ const runtimePayload = await runtimeResponse.json();
804
+ assert(runtimePayload.mode === "workbench" && runtimePayload.csrfToken === runtime.csrf, "workbench runtime should expose mode and csrf token");
805
+ const dashboardData = fs.readFileSync(path.join(workbenchDir, "assets/dashboard-data.js"), "utf8");
806
+ assert(dashboardData.includes("Walkthrough: Closed review debt"), "dashboard data should include closeout walkthrough documents");
807
+ const badOrigin = await fetch(new URL("api/tasks/review-complete", runtime.url), {
808
+ method: "POST",
809
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: "http://127.0.0.1:9" },
810
+ body: JSON.stringify({ taskId: `MODULES/auth/${todayLocal}-module-lifecycle`, confirmText: `${todayLocal}-module-lifecycle`, reviewer: "Human Reviewer" }),
811
+ });
812
+ assert(badOrigin.status === 403, "workbench should reject mismatched origins");
813
+ const badTask = await fetch(new URL("api/tasks/review-complete", runtime.url), {
814
+ method: "POST",
815
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: runtime.url.replace(/\/$/, "") },
816
+ body: JSON.stringify({ taskId: "../bad", confirmText: "bad", reviewer: "Human Reviewer" }),
817
+ });
818
+ assert(badTask.status === 404, "workbench should reject unknown task ids");
819
+ const plannedReview = await fetch(new URL("api/tasks/review-complete", runtime.url), {
820
+ method: "POST",
821
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: runtime.url.replace(/\/$/, "") },
822
+ body: JSON.stringify({ taskId: `TASKS/${todayLocal}-workbench-review`, confirmText: `${todayLocal}-workbench-review`, reviewer: "Human Reviewer" }),
823
+ });
824
+ assert(plannedReview.status === 409, "workbench review completion should reject tasks that are not in review state");
825
+ fs.writeFileSync(
826
+ workbenchReviewProgress,
827
+ fs.readFileSync(workbenchReviewProgress, "utf8").replace(/^## 状态:.*$/m, "## 状态:review"),
828
+ );
829
+ const missingWalkthroughResponse = await fetch(new URL("api/tasks/review-complete", runtime.url), {
830
+ method: "POST",
831
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: runtime.url.replace(/\/$/, "") },
832
+ body: JSON.stringify({ taskId: `TASKS/${todayLocal}-workbench-review`, confirmText: `${todayLocal}-workbench-review`, reviewer: "Human Reviewer", message: "confirmed without walkthrough" }),
833
+ });
834
+ const missingWalkthroughText = await missingWalkthroughResponse.text();
835
+ assert(missingWalkthroughResponse.status === 400, `workbench review completion should require walkthrough, got ${missingWalkthroughResponse.status}: ${missingWalkthroughText}`);
836
+ assert(missingWalkthroughText.includes("walkthrough"), "workbench missing walkthrough rejection should explain walkthrough requirement");
837
+ const workbenchReviewWalkthrough = path.join(lifecycleTarget, "docs/10-WALKTHROUGH/workbench-review-walkthrough.md");
838
+ fs.writeFileSync(
839
+ workbenchReviewWalkthrough,
840
+ "# Walkthrough: Workbench review gate\n\n## Summary\n\nHuman-readable walkthrough for dashboard review confirmation.\n",
841
+ );
842
+ fs.appendFileSync(
843
+ path.join(lifecycleTarget, "docs/10-WALKTHROUGH/Closeout-SSoT.md"),
844
+ `\n| CL-WORKBENCH-REVIEW | 2026-05-21 | Workbench review gate | \`docs/09-PLANNING/TASKS/${todayLocal}-workbench-review/task_plan.md\` | \`docs/09-PLANNING/TASKS/${todayLocal}-workbench-review/review.md\` | \`docs/10-WALKTHROUGH/workbench-review-walkthrough.md\` | pending human review | none | checked-none | pending |\n`,
845
+ );
846
+ acceptNoLessonCandidate(path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-workbench-review`));
847
+ const okResponse = await fetch(new URL("api/tasks/review-complete", runtime.url), {
848
+ method: "POST",
849
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: runtime.url.replace(/\/$/, "") },
850
+ body: JSON.stringify({ taskId: `TASKS/${todayLocal}-workbench-review`, confirmText: `${todayLocal}-workbench-review`, reviewer: "Human Reviewer", message: "confirmed from workbench" }),
851
+ });
852
+ const okText = await okResponse.text();
853
+ assert(okResponse.status === 200, `workbench review completion should pass, got ${okResponse.status}: ${okText}`);
854
+ const okPayload = JSON.parse(okText);
855
+ assert(okPayload.task?.reviewStatus === "confirmed", "workbench review completion should return confirmed task status");
856
+ const closedReviewResponse = await fetch(new URL("api/tasks/review-complete", runtime.url), {
857
+ method: "POST",
858
+ headers: { "content-type": "application/json", "x-harness-csrf": runtime.csrf, origin: runtime.url.replace(/\/$/, "") },
859
+ body: JSON.stringify({ taskId: `TASKS/${todayLocal}-workbench-closed-review`, confirmText: `${todayLocal}-workbench-closed-review`, reviewer: "Human Reviewer", message: "closed debt confirmed from workbench" }),
860
+ });
861
+ const closedReviewText = await closedReviewResponse.text();
862
+ assert(closedReviewResponse.status === 409, `workbench review completion should reject closed closeout tasks, got ${closedReviewResponse.status}: ${closedReviewText}`);
863
+ assert(closedReviewText.includes("review"), "workbench closed closeout rejection should explain review stage boundary");
864
+ } finally {
865
+ workbench.kill("SIGTERM");
866
+ }
867
+ const devDir = path.join(tmpRoot, "dev-workbench");
868
+ const dev = spawn(node, [cli, "dev", "--no-open", "--out-dir", devDir, "--host", "127.0.0.1", "--port", "0", lifecycleTarget], {
869
+ cwd: repoRoot,
870
+ stdio: ["ignore", "pipe", "pipe"],
871
+ });
872
+ const devRuntime = await waitForWorkbench(dev);
873
+ try {
874
+ assert(devRuntime.stdout.includes("outDir="), "harness dev should print the generated outDir");
875
+ const initialRuntime = await (await fetch(new URL("api/runtime", devRuntime.url))).json();
876
+ assert(initialRuntime.mode === "workbench" && initialRuntime.autoRefresh === true, "harness dev should start auto-refreshing workbench runtime");
877
+ assert(fs.readFileSync(path.join(devDir, "index.html"), "utf8").includes("__HARNESS_WORKBENCH__ = true"), "harness dev should enable workbench runtime in generated index");
878
+ const marker = `dev-refresh-${Date.now()}`;
879
+ fs.appendFileSync(
880
+ path.join(lifecycleTarget, `docs/09-PLANNING/TASKS/${todayLocal}-phase-2-lifecycle/progress.md`),
881
+ `\n\n## Dev Refresh Marker\n\n${marker}\n`,
882
+ );
883
+ await waitForCondition(async () => {
884
+ const runtimePayload = await (await fetch(new URL("api/runtime", devRuntime.url))).json();
885
+ if (runtimePayload.snapshotVersion === initialRuntime.snapshotVersion) return false;
886
+ const dashboardData = fs.readFileSync(path.join(devDir, "assets/dashboard-data.js"), "utf8");
887
+ return dashboardData.includes(marker) ? runtimePayload : false;
888
+ }, "harness dev should regenerate dashboard data after docs changes");
889
+ } finally {
890
+ dev.kill("SIGTERM");
891
+ }
892
+
893
+ const zhRegistryTarget = path.join(tmpRoot, "zh-module-registry-target");
894
+ fs.mkdirSync(zhRegistryTarget);
895
+ expectJson(["init", "--locale", "zh-CN", "--capabilities", "core,module-parallel", zhRegistryTarget]);
896
+ assert(fs.existsSync(path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/Session-Prompt-Pack.md")), "module-parallel init should create a session prompt pack");
897
+ assert(fs.existsSync(path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/_module-template/module_plan.md")), "module-parallel init should create a module plan template");
898
+ assert(fs.existsSync(path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/_module-template/session_prompt.md")), "module-parallel init should create a module session prompt template");
899
+ assert(fs.existsSync(path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/_task-template/review.md")), "module-parallel init should create complete module task templates");
900
+ expectJson(["new-task", "zh-task", "--module", "example", "--title", "中文模块任务", "--locale", "zh-CN", zhRegistryTarget]);
901
+ fs.mkdirSync(path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/example"), { recursive: true });
902
+ fs.writeFileSync(
903
+ path.join(zhRegistryTarget, "docs/09-PLANNING/MODULES/example/module_plan.md"),
904
+ `# 示例模块计划\n\n## 步骤\n\n| 步骤 ID | 名称 | 状态 | 任务计划 | 依赖 |\n| --- | --- | --- | --- | --- |\n| EXM-01 | 启动 | planned | docs/09-PLANNING/MODULES/example/${todayLocal}-zh-task/task_plan.md | none |\n`,
905
+ );
906
+ expectJson(["module-step", "example", "EXM-01", "--state", "done", zhRegistryTarget]);
907
+ const zhRegistryContent = fs.readFileSync(path.join(zhRegistryTarget, "docs/09-PLANNING/Module-Registry.md"), "utf8");
908
+ assert(zhRegistryContent.includes("| example | 示例模块 | EXM | `codex/example` | EXM-01 | completed |"), "module-step should update zh-CN module registry status/current step");
909
+ const zhGraphDir = path.join(tmpRoot, "zh-module-dashboard");
910
+ expectPass(["dashboard", "--out-dir", zhGraphDir, zhRegistryTarget]);
911
+ const zhGraph = JSON.parse(fs.readFileSync(path.join(zhGraphDir, "data/graph.json"), "utf8"));
912
+ assert(zhGraph.nodes.some((node) => node.type === "module" && node.id === "module:example" && node.state === "completed" && node.currentStep === "EXM-01"), "zh-CN module registry should populate dashboard graph");
913
+ assert(zhGraph.nodes.some((node) => node.type === "step" && node.id === "step:EXM-01" && node.state === "done"), "zh-CN module plan should populate step graph");
914
+ const moduleFiltered = expectJson(["task-list", "--json", "--module", "auth", lifecycleTarget]);
915
+ assert(moduleFiltered.tasks.length === 1 && moduleFiltered.tasks[0].id === `MODULES/auth/${todayLocal}-module-lifecycle`, "task-list --module should filter module tasks");
916
+ expectJson(["new-task", "module-lifecycle", "--title", "同名根任务", "--locale", "zh-CN", lifecycleTarget]);
917
+ const ambiguousTask = run(["task-start", "module-lifecycle", "--message", "ambiguous", lifecycleTarget]);
918
+ assert(ambiguousTask.status !== 0, "ambiguous task short name should fail");
919
+ assert(ambiguousTask.stderr.includes("Ambiguous task reference"), "ambiguous task error should explain ambiguity");
920
+ assert(ambiguousTask.stderr.includes(`TASKS/${todayLocal}-module-lifecycle`) && ambiguousTask.stderr.includes(`MODULES/auth/${todayLocal}-module-lifecycle`), "ambiguous task error should list candidate task paths");
921
+
277
922
  const capTarget = path.join(tmpRoot, "cap-target");
278
923
  fs.mkdirSync(capTarget);
279
924
  expectPass(["add-capability", "dashboard", capTarget]);
@@ -353,6 +998,49 @@ assert(legacyLoose.status === 0, "legacy contract gaps should be advisory withou
353
998
  const legacyStrict = run(["check", "--profile", "target-project", "--strict", legacyContractTarget]);
354
999
  assert(legacyStrict.status !== 0, "strict legacy contract gaps should fail");
355
1000
 
1001
+ const invalidTaskStateTarget = path.join(tmpRoot, "invalid-task-state");
1002
+ fs.mkdirSync(path.join(invalidTaskStateTarget, "docs/09-PLANNING/TASKS/bad-state"), { recursive: true });
1003
+ fs.writeFileSync(path.join(invalidTaskStateTarget, "AGENTS.md"), "# AGENTS\n");
1004
+ fs.writeFileSync(
1005
+ path.join(invalidTaskStateTarget, ".harness-capabilities.json"),
1006
+ JSON.stringify({ version: 1, locale: "en-US", capabilities: [{ name: "core", state: "configured" }] }, null, 2),
1007
+ );
1008
+ fs.writeFileSync(path.join(invalidTaskStateTarget, "docs/09-PLANNING/TASKS/bad-state/task_plan.md"), "# Bad State\n");
1009
+ fs.writeFileSync(path.join(invalidTaskStateTarget, "docs/09-PLANNING/TASKS/bad-state/progress.md"), "# Progress\n\n## Status\n\nin progresss\n");
1010
+ const invalidTaskState = run(["check", "--profile", "target-project", invalidTaskStateTarget]);
1011
+ assert(invalidTaskState.status !== 0, "invalid explicit task state should fail for declared v1 targets");
1012
+ assert(invalidTaskState.stderr.includes("invalid task state"), "invalid task state failure should be explicit");
1013
+ fs.writeFileSync(path.join(invalidTaskStateTarget, "docs/09-PLANNING/TASKS/bad-state/progress.md"), "# Progress\n\n## Status\n\nunknown\n");
1014
+ const explicitUnknownTaskState = run(["check", "--profile", "target-project", invalidTaskStateTarget]);
1015
+ assert(explicitUnknownTaskState.status !== 0, "explicit unknown task state should fail for declared v1 targets");
1016
+ assert(explicitUnknownTaskState.stderr.includes("invalid task state"), "explicit unknown state failure should be explicit");
1017
+
1018
+ const legacyPhaseTableTarget = path.join(tmpRoot, "legacy-phase-table");
1019
+ fs.mkdirSync(path.join(legacyPhaseTableTarget, "docs/09-PLANNING/TASKS/table-active"), { recursive: true });
1020
+ fs.writeFileSync(path.join(legacyPhaseTableTarget, "AGENTS.md"), "# Legacy Agents\n");
1021
+ fs.writeFileSync(path.join(legacyPhaseTableTarget, "docs/09-PLANNING/TASKS/table-active/task_plan.md"), "# Table Active\n");
1022
+ fs.writeFileSync(
1023
+ path.join(legacyPhaseTableTarget, "docs/09-PLANNING/TASKS/table-active/progress.md"),
1024
+ "# Progress\n\n## 阶段状态表\n| Phase | Status | Notes |\n| --- | --- | --- |\n| Phase 1 | Done | ok |\n| Phase 2 | In Progress | active |\n| Phase 3 | Pending | next |\n",
1025
+ );
1026
+ const legacyPhaseStatus = expectJson(["status", "--json", legacyPhaseTableTarget]);
1027
+ assert(legacyPhaseStatus.tasks[0].state === "in_progress", "Agora-style legacy phase table should infer active task state");
1028
+
1029
+ const legacyChineseTarget = path.join(tmpRoot, "legacy-chinese");
1030
+ fs.mkdirSync(path.join(legacyChineseTarget, "docs/09-PLANNING/TASKS/old"), { recursive: true });
1031
+ fs.writeFileSync(path.join(legacyChineseTarget, "AGENTS.md"), "# 中文项目\n\n这是旧 harness 项目。\n");
1032
+ fs.writeFileSync(path.join(legacyChineseTarget, "docs/09-PLANNING/TASKS/old/task_plan.md"), "# 旧任务\n");
1033
+ const legacyChinesePlan = expectJson(["migrate-plan", "--json", legacyChineseTarget]);
1034
+ assert(legacyChinesePlan.locale === "zh-CN", "migrate-plan should infer zh-CN from Chinese legacy project text");
1035
+ assert(
1036
+ legacyChinesePlan.nextCommands.some((command) => command.includes("migrate-run --locale zh-CN")),
1037
+ "migrate-plan should recommend zh-CN migration run for Chinese legacy projects",
1038
+ );
1039
+ assert(
1040
+ legacyChinesePlan.nextCommands.some((command) => command.includes(legacyChineseTarget)),
1041
+ "migrate-plan should keep executable target paths in CLI output",
1042
+ );
1043
+
356
1044
  const legacyAdoptionTarget = path.join(tmpRoot, "legacy-adoption");
357
1045
  fs.mkdirSync(path.join(legacyAdoptionTarget, "docs/09-PLANNING/TASKS/old"), { recursive: true });
358
1046
  const legacyAgents = "# Legacy Agents\n\nLEGACY_DO_NOT_OVERWRITE\n";
@@ -395,9 +1083,176 @@ assert(
395
1083
  adoptedStatus.checkState.details.warnings.some((warning) => warning.includes("adoption-needed")),
396
1084
  "safe-adoption warnings should be routed as adoption-needed",
397
1085
  );
1086
+ assert(adoptedStatus.tasks[0].inferredModule, "legacy task status should expose inferred module classification");
1087
+ assert(adoptedStatus.tasks[0].classificationBucket, "legacy task status should expose classification bucket");
1088
+ const legacyAdoptionDashboard = path.join(tmpRoot, "legacy-adoption-dashboard");
1089
+ expectPass(["dashboard", "--out-dir", legacyAdoptionDashboard, legacyAdoptionTarget]);
1090
+ const legacyAdoptionWarnings = JSON.parse(fs.readFileSync(path.join(legacyAdoptionDashboard, "data/adoption.json"), "utf8"));
1091
+ const firstAdoptionWarning = legacyAdoptionWarnings.warnings?.[0];
1092
+ assert(firstAdoptionWarning?.type, "adoption warning should expose stable type");
1093
+ assert(firstAdoptionWarning?.scope, "adoption warning should expose scope");
1094
+ assert(firstAdoptionWarning?.priority, "adoption warning should expose priority");
1095
+ assert(firstAdoptionWarning?.phase, "adoption warning should expose migration phase");
1096
+ assert(firstAdoptionWarning?.fixability, "adoption warning should expose fixability");
1097
+ assert(firstAdoptionWarning?.status, "adoption warning should expose queue status");
1098
+ assert(firstAdoptionWarning?.confidence, "adoption warning should expose confidence");
1099
+ assert(Array.isArray(firstAdoptionWarning?.affectedPaths), "adoption warning should expose affectedPaths array");
1100
+ assert(firstAdoptionWarning?.affected && firstAdoptionWarning?.requiredAction, "adoption warning should preserve affected and requiredAction fields");
1101
+ const migrationPlan = expectJson(["migrate-plan", "--json", "--limit", "5", legacyAdoptionTarget]);
1102
+ assert(migrationPlan.operation === "migrate-plan", "migrate-plan should report its operation");
1103
+ assert(migrationPlan.compatibility?.preserves?.some((item) => item.includes("AGENTS.md")), "migrate-plan should state preservation rules");
1104
+ assert(migrationPlan.phases?.some((phase) => phase.id === "MP-03"), "migrate-plan should include active task migration phase");
1105
+ assert(migrationPlan.summary?.missingExecutionStrategy >= 1, "migrate-plan should count missing execution strategies");
1106
+ assert(migrationPlan.summary?.missingVisualMap >= 1, "migrate-plan should count missing canonical visual maps");
1107
+ assert(migrationPlan.summary?.visualMapActions >= 1, "migrate-plan should expose visual map action count");
1108
+ assert(migrationPlan.summary?.legacyVisualOnly >= 1, "migrate-plan should expose legacy visual-only count");
1109
+ assert(migrationPlan.summary?.weakBrief >= 1, "migrate-plan should expose weak brief count");
1110
+ assert(migrationPlan.summary?.missingCanonicalVisualMap >= 1, "migrate-plan should expose missing canonical visual map count");
1111
+ assert(migrationPlan.summary?.fullCutoverEligible === false, "legacy migrate-plan should not be full-cutover eligible");
1112
+ assert(migrationPlan.taskActions?.some((action) => action.taskId === "old" && action.files.includes("execution_strategy.md")), "migrate-plan should include task-level file actions");
1113
+ assert(migrationPlan.taskActions?.some((action) => action.taskId === "old" && action.files.includes("visual_map.md")), "migrate-plan should include canonical visual map action");
1114
+ assert(migrationPlan.taskActions?.some((action) => action.taskId === "old" && action.files.includes("brief.md")), "migrate-plan should include active brief migration action");
1115
+ assert(migrationPlan.visualMapActions?.some((action) => action.taskId === "old"), "migrate-plan should expose visual map actions separately");
1116
+ assert(migrationPlan.legacyVisualOnlyTasks?.some((action) => action.taskId === "old"), "migrate-plan should expose legacy visual-only tasks separately");
1117
+ assert(migrationPlan.weakBriefTasks?.some((action) => action.taskId === "old"), "migrate-plan should expose weak brief tasks separately");
1118
+ assert(migrationPlan.taskActions?.some((action) => action.commands.some((command) => command.includes("_task-template/brief.md"))), "migrate-plan should emit a command per active task file");
1119
+ assert(migrationPlan.nextCommands?.some((command) => command.includes("migrate-run")), "migrate-plan should include migrate-run command");
1120
+ assert(migrationPlan.nextCommands?.some((command) => command.includes("migrate-verify --full-cutover")), "migrate-plan should include full-cutover verify command");
1121
+ const migrationPlanText = expectPass(["migrate-plan", "--limit", "3", legacyAdoptionTarget]).stdout;
1122
+ assert(migrationPlanText.includes("Migration Plan"), "migrate-plan text output should have a readable heading");
1123
+ assert(migrationPlanText.includes("legacy residuals:"), "migrate-plan text output should show residual counts");
1124
+ assert(migrationPlanText.includes("full cutover eligible:"), "migrate-plan text output should show full cutover eligibility");
398
1125
  const adoptedStrict = run(["status", "--json", "--strict", legacyAdoptionTarget]);
399
1126
  assert(adoptedStrict.status !== 0, "safe-adoption strict status should still fail on historical contract gaps");
400
1127
 
1128
+ const migrationRunTarget = path.join(tmpRoot, "migration-run");
1129
+ fs.mkdirSync(path.join(migrationRunTarget, "docs/09-PLANNING/TASKS/old"), { recursive: true });
1130
+ fs.writeFileSync(path.join(migrationRunTarget, "AGENTS.md"), "# 旧项目 Agents\n\nLegacy English notes are still present.\n");
1131
+ fs.writeFileSync(path.join(migrationRunTarget, "docs/09-PLANNING/TASKS/old/task_plan.md"), "# Old Task\n\nThis active task predates v1.\n");
1132
+ fs.writeFileSync(path.join(migrationRunTarget, "docs/09-PLANNING/TASKS/old/progress.md"), "# Progress\n\n## Status\n\nplanned\n");
1133
+ spawnSync("git", ["init"], { cwd: migrationRunTarget, encoding: "utf8" });
1134
+ spawnSync("git", ["add", "."], { cwd: migrationRunTarget, encoding: "utf8" });
1135
+ spawnSync("git", ["-c", "user.name=Harness Test", "-c", "user.email=harness@example.test", "commit", "-m", "legacy baseline"], {
1136
+ cwd: migrationRunTarget,
1137
+ encoding: "utf8",
1138
+ });
1139
+ const migrationSessionDir = path.join(tmpRoot, "migration-session");
1140
+ const migrationDashboardDir = path.join(tmpRoot, "migration-dashboard");
1141
+ const migrationRun = expectJson([
1142
+ "migrate-run",
1143
+ "--locale",
1144
+ "zh-CN",
1145
+ "--session-dir",
1146
+ migrationSessionDir,
1147
+ "--out-dir",
1148
+ migrationDashboardDir,
1149
+ migrationRunTarget,
1150
+ ]);
1151
+ assert(migrationRun.operation === "migrate-run", "migrate-run should report its operation");
1152
+ assert(migrationRun.result === "adopted-with-strict-deferred", "legacy migrate-run should keep strict cutover deferred");
1153
+ assert(migrationRun.checks.normal.status !== "fail", "legacy migrate-run should keep normal check usable");
1154
+ assert(migrationRun.checks.strict.status === "fail", "legacy migrate-run should record strict failure");
1155
+ assert(migrationRun.strictDeferred?.owner && migrationRun.strictDeferred?.trigger && migrationRun.strictDeferred?.nextAction, "strict-deferred migration should include owner, trigger, and nextAction");
1156
+ assert(fs.existsSync(migrationRun.sessionPath), "migrate-run should write session.json");
1157
+ assert(fs.existsSync(migrationRun.reportPath), "migrate-run should write report.md");
1158
+ assert(fs.existsSync(path.join(migrationDashboardDir, "index.html")), "migrate-run should generate an HTML dashboard folder");
1159
+ const migrationRegistry = JSON.parse(fs.readFileSync(path.join(migrationRunTarget, ".harness-capabilities.json"), "utf8"));
1160
+ assert(migrationRegistry.locale === "zh-CN", "migrate-run should persist selected locale");
1161
+ assert(migrationRegistry.capabilities.some((capability) => capability.name === "safe-adoption"), "migrate-run should declare safe-adoption");
1162
+ assert(migrationRegistry.capabilities.some((capability) => capability.name === "dashboard"), "migrate-run should declare dashboard");
1163
+ assert(!migrationRun.git.after.staged.length, "migrate-run should not stage target files");
1164
+ assert(
1165
+ spawnSync("git", ["-C", migrationRunTarget, "diff", "--cached", "--name-only"], { encoding: "utf8" }).stdout.trim() === "",
1166
+ "migrate-run should leave the target git index untouched",
1167
+ );
1168
+ const migrationVerify = expectJson(["migrate-verify", "--json", migrationRun.sessionPath]);
1169
+ assert(migrationVerify.status === "pass", "migrate-verify should pass for migrate-run output");
1170
+ assert(migrationVerify.dashboard?.indexPath?.endsWith("index.html"), "migrate-verify should preserve HTML dashboard evidence");
1171
+ const migrationFullCutover = run(["migrate-verify", "--json", "--full-cutover", migrationRun.sessionPath]);
1172
+ assert(migrationFullCutover.status !== 0, "full cutover verify should reject baseline legacy-only migration output");
1173
+
1174
+ const falseSessionPath = path.join(tmpRoot, "false-session.json");
1175
+ fs.writeFileSync(
1176
+ falseSessionPath,
1177
+ JSON.stringify(
1178
+ {
1179
+ ...JSON.parse(fs.readFileSync(migrationRun.sessionPath, "utf8")),
1180
+ dashboard: { dir: migrationRunTarget, indexPath: path.join(migrationRunTarget, "docs/Harness-Ledger.md"), kind: "html-folder" },
1181
+ },
1182
+ null,
1183
+ 2,
1184
+ ),
1185
+ );
1186
+ const falseVerify = run(["migrate-verify", "--json", falseSessionPath]);
1187
+ assert(falseVerify.status !== 0, "migrate-verify should reject non-HTML dashboard evidence");
1188
+
1189
+ const mixedLocaleTarget = path.join(tmpRoot, "mixed-locale");
1190
+ fs.mkdirSync(path.join(mixedLocaleTarget, "docs/09-PLANNING/TASKS/mixed"), { recursive: true });
1191
+ fs.writeFileSync(path.join(mixedLocaleTarget, "AGENTS.md"), "# 中文入口\n\n这是一个中文项目,迁移时需要选择中文或英文模板。\n");
1192
+ fs.writeFileSync(
1193
+ path.join(mixedLocaleTarget, "docs/09-PLANNING/TASKS/mixed/task_plan.md"),
1194
+ "# Legacy task\n\nThis English task plan intentionally contains enough words to make the language decision ambiguous for migration.\n",
1195
+ );
1196
+ const mixedLocaleFail = run(["migrate-run", "--plan-only", mixedLocaleTarget]);
1197
+ assert(mixedLocaleFail.status !== 0, "migrate-run should require --locale for mixed-language targets");
1198
+ assert(mixedLocaleFail.stderr.includes("--locale zh-CN"), "mixed-language failure should tell agents how to choose locale");
1199
+ const mixedLocalePlan = expectJson(["migrate-run", "--plan-only", "--locale", "zh-CN", "--session-dir", path.join(tmpRoot, "mixed-locale-session"), mixedLocaleTarget]);
1200
+ assert(mixedLocalePlan.result === "plan-only", "migrate-run --plan-only should produce a plan-only session");
1201
+ assert(mixedLocalePlan.localeDecision.selected === "zh-CN", "migrate-run --locale should resolve mixed-language decision");
1202
+ const planOnlyVerify = run(["migrate-verify", "--json", mixedLocalePlan.sessionPath]);
1203
+ assert(planOnlyVerify.status !== 0, "migrate-verify should reject plan-only sessions as completion evidence");
1204
+
1205
+ const dirtyMigrationTarget = path.join(tmpRoot, "dirty-migration");
1206
+ fs.mkdirSync(dirtyMigrationTarget);
1207
+ fs.writeFileSync(path.join(dirtyMigrationTarget, "AGENTS.md"), "# Legacy\n");
1208
+ spawnSync("git", ["init"], { cwd: dirtyMigrationTarget, encoding: "utf8" });
1209
+ spawnSync("git", ["add", "."], { cwd: dirtyMigrationTarget, encoding: "utf8" });
1210
+ spawnSync("git", ["-c", "user.name=Harness Test", "-c", "user.email=harness@example.test", "commit", "-m", "baseline"], {
1211
+ cwd: dirtyMigrationTarget,
1212
+ encoding: "utf8",
1213
+ });
1214
+ fs.writeFileSync(path.join(dirtyMigrationTarget, "unreviewed.txt"), "dirty\n");
1215
+ const dirtyMigration = run(["migrate-run", "--locale", "en-US", dirtyMigrationTarget]);
1216
+ assert(dirtyMigration.status !== 0, "migrate-run should stop on dirty git targets by default");
1217
+ assert(dirtyMigration.stderr.includes("--allow-dirty"), "dirty guard should explain --allow-dirty escape hatch");
1218
+
1219
+ const forgedStrictSessionPath = path.join(tmpRoot, "forged-strict-session.json");
1220
+ const forgedStrictSession = {
1221
+ ...JSON.parse(fs.readFileSync(migrationRun.sessionPath, "utf8")),
1222
+ result: "complete",
1223
+ checks: { ...migrationRun.checks, strict: { status: "pass", failures: 0, warnings: 0 } },
1224
+ strictDeferred: null,
1225
+ };
1226
+ fs.writeFileSync(forgedStrictSessionPath, `${JSON.stringify(forgedStrictSession, null, 2)}\n`);
1227
+ const forgedStrictVerify = run(["migrate-verify", "--json", forgedStrictSessionPath]);
1228
+ assert(forgedStrictVerify.status !== 0, "migrate-verify should rerun strict and reject forged strict pass sessions");
1229
+
1230
+ const fakeDashboardDir = path.join(tmpRoot, "fake-dashboard");
1231
+ const fakeDashboardPath = path.join(fakeDashboardDir, "index.html");
1232
+ fs.mkdirSync(path.join(fakeDashboardDir, "assets"), { recursive: true });
1233
+ fs.mkdirSync(path.join(fakeDashboardDir, "data"), { recursive: true });
1234
+ fs.writeFileSync(fakeDashboardPath, '<html><script src="assets/dashboard-data.js"></script></html>\n');
1235
+ fs.writeFileSync(path.join(fakeDashboardDir, "assets/dashboard-data.js"), 'window.__HARNESS_DASHBOARD__ = {"status":{"schemaVersion":2,"project":{"name":"WrongProject"},"checkState":{}},"adoption":{"warnings":[]}};\n');
1236
+ fs.writeFileSync(path.join(fakeDashboardDir, "data/status.json"), "{}\n");
1237
+ fs.writeFileSync(path.join(fakeDashboardDir, "data/adoption.json"), "{}\n");
1238
+ const fakeDashboardSessionPath = path.join(tmpRoot, "fake-dashboard-session.json");
1239
+ const fakeDashboardSession = {
1240
+ ...JSON.parse(fs.readFileSync(migrationRun.sessionPath, "utf8")),
1241
+ dashboard: { dir: fakeDashboardDir, indexPath: fakeDashboardPath, kind: "html-folder" },
1242
+ };
1243
+ fs.writeFileSync(fakeDashboardSessionPath, `${JSON.stringify(fakeDashboardSession, null, 2)}\n`);
1244
+ const fakeDashboardVerify = run(["migrate-verify", "--json", fakeDashboardSessionPath]);
1245
+ assert(fakeDashboardVerify.status !== 0, "migrate-verify should reject arbitrary HTML as dashboard evidence");
1246
+
1247
+ const missingGitSessionPath = path.join(tmpRoot, "missing-git-session.json");
1248
+ const missingGitSession = {
1249
+ ...JSON.parse(fs.readFileSync(migrationRun.sessionPath, "utf8")),
1250
+ git: undefined,
1251
+ };
1252
+ fs.writeFileSync(missingGitSessionPath, `${JSON.stringify(missingGitSession, null, 2)}\n`);
1253
+ const missingGitVerify = run(["migrate-verify", "--json", missingGitSessionPath]);
1254
+ assert(missingGitVerify.status !== 0, "migrate-verify should require git audit metadata");
1255
+
401
1256
  const legacyCheckerOnlyTarget = path.join(tmpRoot, "legacy-checker-only");
402
1257
  fs.mkdirSync(legacyCheckerOnlyTarget);
403
1258
  expectPass(["add-capability", "safe-adoption", "--locale", "en-US", legacyCheckerOnlyTarget]);
@@ -447,6 +1302,63 @@ if (fs.existsSync(mingjingDocs)) {
447
1302
  assert(before === after, "mingjing docs changed during status/check/dashboard smoke");
448
1303
  }
449
1304
 
1305
+ // --- Date prefix auto-generation tests ---
1306
+ const datePrefixTarget = path.join(tmpRoot, "date-prefix-target");
1307
+ fs.mkdirSync(datePrefixTarget);
1308
+ expectJson(["init", "--locale", "en-US", "--capabilities", "core,dashboard", datePrefixTarget]);
1309
+
1310
+ // 1. Bare slug gets auto-prefixed with local date
1311
+ const datePrefixCreate = expectJson(["new-task", "my-feature", "--title", "My Feature", datePrefixTarget]);
1312
+ assert(datePrefixCreate.task?.shortId === `${todayLocal}-my-feature`, "new-task bare slug should auto-prefix local date");
1313
+ assert(datePrefixCreate.task?.id === `TASKS/${todayLocal}-my-feature`, "new-task bare slug task id should include date prefix");
1314
+ assert(datePrefixCreate.task?.title === "My Feature", "new-task should use explicit title, not dated id");
1315
+ assert(
1316
+ fs.existsSync(path.join(datePrefixTarget, `docs/09-PLANNING/TASKS/${todayLocal}-my-feature/task_plan.md`)),
1317
+ "new-task bare slug should create dated directory",
1318
+ );
1319
+
1320
+ // 2. Already-dated slug should NOT double-prefix
1321
+ const alreadyDated = expectJson(["new-task", `${todayLocal}-existing-date`, "--title", "Already Dated", datePrefixTarget]);
1322
+ assert(alreadyDated.task?.shortId === `${todayLocal}-existing-date`, "new-task already-dated slug should not double-prefix");
1323
+ assert(
1324
+ fs.existsSync(path.join(datePrefixTarget, `docs/09-PLANNING/TASKS/${todayLocal}-existing-date/task_plan.md`)),
1325
+ "new-task already-dated slug should create directory without double date",
1326
+ );
1327
+ assert(
1328
+ !fs.existsSync(path.join(datePrefixTarget, `docs/09-PLANNING/TASKS/${todayLocal}-${todayLocal}-existing-date`)),
1329
+ "new-task already-dated slug must not create double-dated directory",
1330
+ );
1331
+
1332
+ // 3. Module task also gets date prefix
1333
+ const moduleWithDate = expectJson(["new-task", "module-feat", "--module", "payments", "--title", "Module Feature", datePrefixTarget]);
1334
+ assert(moduleWithDate.task?.shortId === `${todayLocal}-module-feat`, "new-task --module bare slug should auto-prefix local date");
1335
+ assert(moduleWithDate.task?.id === `MODULES/payments/${todayLocal}-module-feat`, "new-task --module task id should include date prefix");
1336
+ assert(
1337
+ fs.existsSync(path.join(datePrefixTarget, `docs/09-PLANNING/MODULES/payments/${todayLocal}-module-feat/task_plan.md`)),
1338
+ "new-task --module should create dated directory under module",
1339
+ );
1340
+
1341
+ // 4. Bare slug lifecycle resolution: task-start resolves "my-feature" to dated directory
1342
+ const startByBareSlug = expectJson(["task-start", "my-feature", "--message", "start via bare slug", datePrefixTarget]);
1343
+ assert(startByBareSlug.task?.id === `TASKS/${todayLocal}-my-feature`, "task-start should resolve bare slug to dated directory");
1344
+ assert(startByBareSlug.task?.state === "in_progress", "task-start via bare slug should transition to in_progress");
1345
+
1346
+ // 5. task-log also resolves bare slug
1347
+ expectJson(["task-log", "my-feature", "--message", "log via bare slug", "--evidence", "command:TARGET:test:passed", datePrefixTarget]);
1348
+
1349
+ // 6. Ambiguous multi-match: create a second dated directory with same bare slug
1350
+ fs.mkdirSync(path.join(datePrefixTarget, "docs/09-PLANNING/TASKS/2025-01-01-my-feature"), { recursive: true });
1351
+ fs.writeFileSync(path.join(datePrefixTarget, "docs/09-PLANNING/TASKS/2025-01-01-my-feature/task_plan.md"), "# Old\n");
1352
+ const ambiguousBareSlug = run(["task-log", "my-feature", "--message", "ambiguous", datePrefixTarget]);
1353
+ assert(ambiguousBareSlug.status !== 0, "bare slug matching multiple dated directories should fail");
1354
+ assert(ambiguousBareSlug.stderr.includes("Ambiguous task reference"), "ambiguous bare slug should report ambiguity");
1355
+ assert(ambiguousBareSlug.stderr.includes(`${todayLocal}-my-feature`) && ambiguousBareSlug.stderr.includes("2025-01-01-my-feature"), "ambiguous error should list both dated candidates");
1356
+
1357
+ // 7. Title preservation: title should be the semantic slug, not the date-id
1358
+ const noTitleCreate = expectJson(["new-task", "auto-title-check", datePrefixTarget]);
1359
+ assert(noTitleCreate.task?.title === "auto-title-check", "new-task without --title should use semantic slug as display title, not dated id");
1360
+ assert(noTitleCreate.task?.shortId === `${todayLocal}-auto-title-check`, "new-task without --title should still date-prefix the shortId");
1361
+
450
1362
  console.log("Harness v1 tests passed");
451
1363
 
452
1364
  function hasLocalAbsolutePath(content) {