@hunter-harness/workflow-harness 0.2.30 → 0.2.31

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 (146) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -3
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +3 -3
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -3
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +6 -6
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +10 -4
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +17 -17
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  11. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  12. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -12
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +3 -3
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -3
  16. package/harness/bundles/general/claude-code/scripts/harness_gate.py +97 -14
  17. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +364 -74
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +3 -3
  21. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
  22. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -3
  23. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +6 -6
  24. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +10 -4
  25. package/harness/bundles/general/codebuddy/harness-plan/reference.md +17 -17
  26. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  27. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  28. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  29. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -12
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -3
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +3 -3
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -3
  33. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +97 -14
  34. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +364 -74
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +3 -3
  38. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -3
  39. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -3
  40. package/harness/bundles/general/codex/harness-plan/SKILL.md +6 -6
  41. package/harness/bundles/general/codex/harness-plan/checklist.md +10 -4
  42. package/harness/bundles/general/codex/harness-plan/reference.md +17 -17
  43. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  44. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  45. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  46. package/harness/bundles/general/codex/harness-run/reference.md +3 -12
  47. package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -3
  48. package/harness/bundles/general/codex/harness-sync/SKILL.md +3 -3
  49. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -3
  50. package/harness/bundles/general/codex/scripts/harness_gate.py +97 -14
  51. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +364 -74
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +3 -3
  55. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -3
  56. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -3
  57. package/harness/bundles/general/cursor/harness-plan/SKILL.md +6 -6
  58. package/harness/bundles/general/cursor/harness-plan/checklist.md +10 -4
  59. package/harness/bundles/general/cursor/harness-plan/reference.md +17 -17
  60. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  61. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  62. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  63. package/harness/bundles/general/cursor/harness-run/reference.md +3 -12
  64. package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -3
  65. package/harness/bundles/general/cursor/harness-sync/SKILL.md +3 -3
  66. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -3
  67. package/harness/bundles/general/cursor/scripts/harness_gate.py +97 -14
  68. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +364 -74
  69. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  70. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -3
  71. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
  72. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +3 -3
  73. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
  74. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -3
  75. package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -3
  76. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +6 -6
  77. package/harness/bundles/java/claude-code/harness-plan/checklist.md +10 -4
  78. package/harness/bundles/java/claude-code/harness-plan/reference.md +17 -17
  79. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  80. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  81. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -3
  82. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +3 -3
  83. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -3
  84. package/harness/bundles/java/claude-code/scripts/harness_gate.py +97 -14
  85. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +364 -74
  86. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  87. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -3
  88. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
  89. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +3 -3
  90. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
  91. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -3
  92. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +3 -3
  93. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +6 -6
  94. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +10 -4
  95. package/harness/bundles/java/codebuddy/harness-plan/reference.md +17 -17
  96. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  97. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  98. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -3
  99. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +3 -3
  100. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -3
  101. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +97 -14
  102. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +364 -74
  103. package/harness/bundles/java/codex/.harness-build.json +1 -1
  104. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -3
  105. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
  106. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +3 -3
  107. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -3
  108. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -3
  109. package/harness/bundles/java/codex/harness-package/SKILL.md +3 -3
  110. package/harness/bundles/java/codex/harness-plan/SKILL.md +6 -6
  111. package/harness/bundles/java/codex/harness-plan/checklist.md +10 -4
  112. package/harness/bundles/java/codex/harness-plan/reference.md +17 -17
  113. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  114. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  115. package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -3
  116. package/harness/bundles/java/codex/harness-sync/SKILL.md +3 -3
  117. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -3
  118. package/harness/bundles/java/codex/scripts/harness_gate.py +97 -14
  119. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +364 -74
  120. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  121. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -3
  122. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
  123. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +3 -3
  124. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -3
  125. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -3
  126. package/harness/bundles/java/cursor/harness-package/SKILL.md +3 -3
  127. package/harness/bundles/java/cursor/harness-plan/SKILL.md +6 -6
  128. package/harness/bundles/java/cursor/harness-plan/checklist.md +10 -4
  129. package/harness/bundles/java/cursor/harness-plan/reference.md +17 -17
  130. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  131. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  132. package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -3
  133. package/harness/bundles/java/cursor/harness-sync/SKILL.md +3 -3
  134. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -3
  135. package/harness/bundles/java/cursor/scripts/harness_gate.py +97 -14
  136. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +364 -74
  137. package/harness/manifests/general/claude-code.json +19 -19
  138. package/harness/manifests/general/codebuddy.json +19 -19
  139. package/harness/manifests/general/codex.json +19 -19
  140. package/harness/manifests/general/cursor.json +19 -19
  141. package/harness/manifests/java/claude-code.json +19 -19
  142. package/harness/manifests/java/codebuddy.json +19 -19
  143. package/harness/manifests/java/codex.json +19 -19
  144. package/harness/manifests/java/cursor.json +19 -19
  145. package/hunter-workflow-family.json +4 -4
  146. package/package.json +1 -1
@@ -51,6 +51,28 @@ def _result_error(code: str, message: str) -> dict[str, Any]:
51
51
  return {"ok": False, "code": code, "error": message}
52
52
 
53
53
 
54
+ def _normalize_header(value: str) -> str:
55
+ return re.sub(r"[\s_-]+", "", value).lower()
56
+
57
+
58
+ def _column_index(
59
+ headers: list[str],
60
+ *aliases: str,
61
+ ) -> int | None:
62
+ normalized = {
63
+ _normalize_header(header): index for index, header in enumerate(headers)
64
+ }
65
+ for alias in aliases:
66
+ index = normalized.get(_normalize_header(alias))
67
+ if index is not None:
68
+ return index
69
+ return None
70
+
71
+
72
+ def _table_cells(line: str) -> list[str]:
73
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
74
+
75
+
54
76
  def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
55
77
  """C9: parse test-scenarios.md tables, extracting scenario rows.
56
78
 
@@ -60,42 +82,100 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
60
82
  text = Path(scenarios_path).read_text(encoding="utf-8-sig")
61
83
  lines = text.splitlines()
62
84
  scenarios: list[dict[str, str]] = []
63
- # Find all tables whose header includes "ID" and "优先级" (or "priority").
64
85
  i = 0
65
86
  while i < len(lines):
66
87
  line = lines[i].strip()
67
- if line.startswith("|") and "ID" in line and ("优先级" in line or "priority" in line):
68
- header_line = line
69
- headers = [h.strip() for h in header_line.strip("|").split("|")]
70
- col_map: dict[str, int] = {}
71
- for idx, name in enumerate(headers):
72
- key = name.lower()
73
- col_map[key] = idx
74
- # Skip separator row
75
- i += 2
76
- while i < len(lines):
77
- row = lines[i].strip()
88
+ if line.startswith("|"):
89
+ headers = _table_cells(line)
90
+ id_index = _column_index(headers, "ID", "#", "编号", "场景 ID")
91
+ scenario_index = _column_index(
92
+ headers,
93
+ "场景",
94
+ "场景描述",
95
+ "scenario",
96
+ "description",
97
+ )
98
+ if id_index is None or scenario_index is None:
99
+ i += 1
100
+ continue
101
+ priority_index = _column_index(headers, "优先级", "priority")
102
+ category_index = _column_index(headers, "分类", "category")
103
+ verification_index = _column_index(
104
+ headers,
105
+ "验证方式",
106
+ "verification",
107
+ "可复用证据",
108
+ )
109
+ owner_phase_index = _column_index(
110
+ headers,
111
+ "owner phase",
112
+ "ownerPhase",
113
+ "负责阶段",
114
+ )
115
+ execution_tier_index = _column_index(
116
+ headers,
117
+ "执行层级",
118
+ "execution tier",
119
+ "tier",
120
+ )
121
+ expected_index = _column_index(headers, "预期", "expected")
122
+
123
+ row_index = i + 2
124
+ while row_index < len(lines):
125
+ row = lines[row_index].strip()
78
126
  if not row.startswith("|"):
79
127
  break
80
- cells = [c.strip() for c in row.strip("|").split("|")]
81
- if len(cells) < len(headers):
82
- i += 1
128
+ cells = _table_cells(row)
129
+ if max(id_index, scenario_index) >= len(cells):
130
+ row_index += 1
83
131
  continue
84
- scenario: dict[str, str] = {}
85
- for cn, en in (
86
- ("ID", "id"),
87
- ("优先级", "priority"),
88
- ("场景", "scenario"),
89
- ("验证方式", "verification"),
90
- ("owner phase", "ownerPhase"),
132
+ complete_row = len(cells) == len(headers)
133
+ scenario_id = cells[id_index].strip()
134
+ if not scenario_id or set(scenario_id) <= {"-", ":"}:
135
+ row_index += 1
136
+ continue
137
+ priority = (
138
+ cells[priority_index].strip().upper()
139
+ if priority_index is not None
140
+ and priority_index < len(cells)
141
+ and cells[priority_index].strip()
142
+ else "P1"
143
+ )
144
+ scenario = {
145
+ "id": scenario_id,
146
+ "priority": priority,
147
+ "scenario": cells[scenario_index].strip(),
148
+ "ownerPhase": (
149
+ cells[owner_phase_index].strip()
150
+ if owner_phase_index is not None
151
+ and complete_row
152
+ and owner_phase_index < len(cells)
153
+ and cells[owner_phase_index].strip()
154
+ else "test"
155
+ ),
156
+ "requiredEvidenceKind": PRIORITY_EVIDENCE_KIND.get(
157
+ priority,
158
+ "advisory",
159
+ ),
160
+ }
161
+ if category_index is not None and category_index < len(cells):
162
+ if cells[category_index].strip():
163
+ scenario["category"] = cells[category_index].strip()
164
+ for key, index in (
165
+ ("verification", verification_index),
166
+ ("executionTier", execution_tier_index),
167
+ ("expected", expected_index),
91
168
  ):
92
- if cn.lower() in col_map:
93
- scenario[en] = cells[col_map[cn.lower()]]
94
- # Derive requiredEvidenceKind from priority
95
- priority = scenario.get("priority", "")
96
- scenario["requiredEvidenceKind"] = PRIORITY_EVIDENCE_KIND.get(priority, "advisory")
169
+ if (
170
+ complete_row
171
+ and index is not None
172
+ and index < len(cells)
173
+ and cells[index].strip()
174
+ ):
175
+ scenario[key] = cells[index].strip()
97
176
  scenarios.append(scenario)
98
- i += 1
177
+ row_index += 1
178
+ i = row_index
99
179
  continue
100
180
  i += 1
101
181
  return scenarios
@@ -110,40 +190,52 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
110
190
  """
111
191
  text = Path(plan_path).read_text(encoding="utf-8-sig")
112
192
  lines = text.splitlines()
113
- # Find the task table: a header row starting with "| #" followed by a separator row.
114
- header_idx = -1
115
- for i, line in enumerate(lines):
116
- stripped = line.strip()
117
- if stripped.startswith("|") and "#" in stripped and "任务" in stripped:
118
- header_idx = i
119
- break
120
- if header_idx < 0:
121
- return []
122
- header_line = lines[header_idx]
123
- headers = [h.strip() for h in header_line.strip().strip("|").split("|")]
124
- # Map header names to column indices (case-insensitive, English keys).
125
- col_map: dict[str, int] = {}
126
- for idx, name in enumerate(headers):
127
- key = name.lower()
128
- col_map[key] = idx
129
193
  tasks: list[dict[str, str]] = []
130
- for line in lines[header_idx + 2 :]: # skip header + separator
131
- stripped = line.strip()
132
- if not stripped.startswith("|"):
133
- break # end of table
134
- cells = [c.strip() for c in stripped.strip("|").split("|")]
135
- if len(cells) < len(headers):
194
+ i = 0
195
+ while i < len(lines):
196
+ line = lines[i].strip()
197
+ if not line.startswith("|"):
198
+ i += 1
199
+ continue
200
+ headers = _table_cells(line)
201
+ number_index = _column_index(headers, "#", "任务编号", "task ID")
202
+ task_index = _column_index(headers, "任务", "task")
203
+ if number_index is None or task_index is None:
204
+ i += 1
136
205
  continue
137
- task: dict[str, str] = {}
138
- # Chinese headers
139
- for cn, en in (("#", "num"), ("簇", "cluster"), ("任务", "task")):
140
- if cn in col_map:
141
- task[en] = cells[col_map[cn]]
142
- # English optional columns
143
- for en in ("ownerPhase", "implementationDoneWhen", "verificationPhase", "requiresExplicitAuthority"):
144
- if en.lower() in col_map:
145
- task[en] = cells[col_map[en.lower()]]
146
- tasks.append(task)
206
+ cluster_index = _column_index(headers, "簇", "cluster")
207
+ optional_indices = {
208
+ name: _column_index(headers, name)
209
+ for name in (
210
+ "ownerPhase",
211
+ "implementationDoneWhen",
212
+ "verificationPhase",
213
+ "requiresExplicitAuthority",
214
+ )
215
+ }
216
+ row_index = i + 2
217
+ while row_index < len(lines):
218
+ row = lines[row_index].strip()
219
+ if not row.startswith("|"):
220
+ break
221
+ cells = _table_cells(row)
222
+ if len(cells) < len(headers):
223
+ row_index += 1
224
+ continue
225
+ number = cells[number_index].strip()
226
+ task_text = cells[task_index].strip()
227
+ if not number or not task_text or set(number) <= {"-", ":"}:
228
+ row_index += 1
229
+ continue
230
+ task: dict[str, str] = {"num": number, "task": task_text}
231
+ if cluster_index is not None and cells[cluster_index].strip():
232
+ task["cluster"] = cells[cluster_index].strip()
233
+ for name, index in optional_indices.items():
234
+ if index is not None and cells[index].strip():
235
+ task[name] = cells[index].strip()
236
+ tasks.append(task)
237
+ row_index += 1
238
+ i = row_index
147
239
  return tasks
148
240
 
149
241
 
@@ -325,6 +417,20 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
325
417
  # C8: validate ownerPhase values in plan.md task table.
326
418
  plan_path = staging / "plans" / f"{change_name}-plan.md"
327
419
  tasks = parse_plan_tasks(plan_path)
420
+ if not tasks:
421
+ return _result_error(
422
+ "PLAN_TASKS_EMPTY",
423
+ f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
424
+ )
425
+ task_ids = [str(task.get("num") or "").strip() for task in tasks]
426
+ duplicate_task_ids = sorted(
427
+ {task_id for task_id in task_ids if task_ids.count(task_id) > 1}
428
+ )
429
+ if duplicate_task_ids:
430
+ return _result_error(
431
+ "PLAN_TASK_ID_DUPLICATE",
432
+ "duplicate task IDs: " + ", ".join(duplicate_task_ids),
433
+ )
328
434
  for task in tasks:
329
435
  owner = task.get("ownerPhase")
330
436
  if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
@@ -339,6 +445,37 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
339
445
  # C9: parse test-scenarios.md for scenario manifest.
340
446
  scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
341
447
  scenarios = parse_test_scenarios(scenarios_path)
448
+ if not scenarios:
449
+ return _result_error(
450
+ "PLAN_SCENARIOS_EMPTY",
451
+ f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
452
+ )
453
+ scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
454
+ duplicate_scenario_ids = sorted(
455
+ {
456
+ scenario_id
457
+ for scenario_id in scenario_ids
458
+ if scenario_ids.count(scenario_id) > 1
459
+ }
460
+ )
461
+ if duplicate_scenario_ids:
462
+ return _result_error(
463
+ "PLAN_SCENARIO_ID_DUPLICATE",
464
+ "duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
465
+ )
466
+ for scenario in scenarios:
467
+ priority = str(scenario.get("priority") or "").upper()
468
+ if priority not in PRIORITY_EVIDENCE_KIND:
469
+ return _result_error(
470
+ "PLAN_SCENARIO_PRIORITY_INVALID",
471
+ f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
472
+ )
473
+ owner = str(scenario.get("ownerPhase") or "")
474
+ if owner not in VALID_OWNER_PHASES:
475
+ return _result_error(
476
+ "PLAN_SCENARIO_OWNER_PHASE_INVALID",
477
+ f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
478
+ )
342
479
 
343
480
  digest = hashlib.sha256()
344
481
  artifact_names: list[str] = []
@@ -374,6 +511,37 @@ def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
374
511
  )
375
512
 
376
513
 
514
+ def _validate_plan_start(
515
+ change_dir: Path,
516
+ run_id: str,
517
+ attempt: int,
518
+ ) -> dict[str, Any]:
519
+ events_path = harness_events.events_path(change_dir)
520
+ try:
521
+ events = harness_events.load_events(events_path)
522
+ except (OSError, ValueError) as exc:
523
+ return _result_error("EVENTS_PARSE_ERROR", str(exc))
524
+ matching = [
525
+ event
526
+ for event in events
527
+ if event.get("phase") == "plan"
528
+ and event.get("type") == "phase.start"
529
+ and event.get("run_id") == run_id
530
+ and event.get("attempt") == attempt
531
+ ]
532
+ if not matching:
533
+ return _result_error(
534
+ "PHASE_START_MISSING",
535
+ "no matching plan phase.start event found for finalizer runId/attempt",
536
+ )
537
+ if len(matching) > 1:
538
+ return _result_error(
539
+ "PHASE_START_DUPLICATE",
540
+ f"found {len(matching)} matching plan phase.start events",
541
+ )
542
+ return {"ok": True, "phaseStartCount": 1}
543
+
544
+
377
545
  def verify_plan(change_dir: Path) -> dict[str, Any]:
378
546
  """Read-only verification of a finalized plan (retro §5.8).
379
547
 
@@ -405,6 +573,18 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
405
573
  files_list = receipt.get("files")
406
574
  if not isinstance(files_list, list) or not files_list:
407
575
  return _result_error("RECEIPT_INVALID", "receipt files list missing or empty")
576
+ if receipt.get("status") != "finalized":
577
+ return _result_error(
578
+ "RECEIPT_NOT_FINALIZED",
579
+ f"receipt status is {receipt.get('status')!r}, expected 'finalized'",
580
+ )
581
+ receipt_run_id = str(receipt.get("runId") or "").strip()
582
+ receipt_attempt = receipt.get("attempt")
583
+ if not receipt_run_id or not isinstance(receipt_attempt, int):
584
+ return _result_error(
585
+ "RECEIPT_INVALID",
586
+ "receipt runId/attempt identity is missing or malformed",
587
+ )
408
588
 
409
589
  # Recompute artifacts hash from published files.
410
590
  digest = hashlib.sha256()
@@ -453,19 +633,105 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
453
633
  "status": fm.get("status"),
454
634
  }
455
635
 
636
+ plan_path = change_dir / "plans" / f"{change_name}-plan.md"
637
+ expected_tasks = parse_plan_tasks(plan_path)
638
+ if not expected_tasks:
639
+ return _result_error(
640
+ "PLAN_TASKS_EMPTY",
641
+ f"no task rows parsed from {plan_path.relative_to(change_dir).as_posix()}",
642
+ )
643
+ checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
644
+ if not checkpoints_path.is_file():
645
+ return _result_error(
646
+ "IMPLEMENTATION_CHECKPOINTS_MISSING",
647
+ f"derived task metadata missing: {checkpoints_path}",
648
+ )
649
+ try:
650
+ checkpoints = json.loads(
651
+ checkpoints_path.read_text(encoding="utf-8-sig")
652
+ )
653
+ except (OSError, json.JSONDecodeError) as exc:
654
+ return _result_error("IMPLEMENTATION_CHECKPOINTS_INVALID", str(exc))
655
+ actual_tasks = checkpoints.get("tasks") if isinstance(checkpoints, dict) else None
656
+ expected_task_ids = [str(item.get("num") or "") for item in expected_tasks]
657
+ actual_task_ids = (
658
+ [str(item.get("num") or "") for item in actual_tasks if isinstance(item, dict)]
659
+ if isinstance(actual_tasks, list)
660
+ else []
661
+ )
662
+ if expected_task_ids != actual_task_ids:
663
+ return _result_error(
664
+ "IMPLEMENTATION_CHECKPOINTS_DRIFT",
665
+ "derived task IDs do not match the finalized plan: "
666
+ f"expected={expected_task_ids}, actual={actual_task_ids}",
667
+ )
668
+
669
+ scenarios_path = (
670
+ change_dir / "plans" / f"{change_name}-test-scenarios.md"
671
+ )
672
+ expected_scenarios = parse_test_scenarios(scenarios_path)
673
+ if not expected_scenarios:
674
+ return _result_error(
675
+ "PLAN_SCENARIOS_EMPTY",
676
+ f"no scenario rows parsed from {scenarios_path.relative_to(change_dir).as_posix()}",
677
+ )
678
+ scenario_manifest_path = change_dir / "meta" / "scenario-manifest.json"
679
+ if not scenario_manifest_path.is_file():
680
+ return _result_error(
681
+ "SCENARIO_MANIFEST_MISSING",
682
+ f"derived scenario metadata missing: {scenario_manifest_path}",
683
+ )
684
+ try:
685
+ manifest = json.loads(
686
+ scenario_manifest_path.read_text(encoding="utf-8-sig")
687
+ )
688
+ except (OSError, json.JSONDecodeError) as exc:
689
+ return _result_error("SCENARIO_MANIFEST_INVALID", str(exc))
690
+ actual_scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
691
+ if not isinstance(actual_scenarios, list) or not actual_scenarios:
692
+ return _result_error(
693
+ "SCENARIO_MANIFEST_EMPTY",
694
+ "scenario-manifest.json must contain at least one scenario",
695
+ )
696
+
697
+ def scenario_identity(item: dict[str, Any]) -> tuple[str, str, str, str]:
698
+ return (
699
+ str(item.get("id") or ""),
700
+ str(item.get("priority") or ""),
701
+ str(item.get("ownerPhase") or ""),
702
+ str(item.get("requiredEvidenceKind") or ""),
703
+ )
704
+
705
+ expected_scenario_identities = [
706
+ scenario_identity(item) for item in expected_scenarios
707
+ ]
708
+ actual_scenario_identities = [
709
+ scenario_identity(item)
710
+ for item in actual_scenarios
711
+ if isinstance(item, dict)
712
+ ]
713
+ if expected_scenario_identities != actual_scenario_identities:
714
+ return _result_error(
715
+ "SCENARIO_MANIFEST_DRIFT",
716
+ "derived scenario identities do not match the finalized scenario table",
717
+ )
718
+
456
719
  # Validate gate-policy.json is parseable JSON.
457
720
  gate_policy_path = change_dir / "meta" / "gate-policy.json"
721
+ if not gate_policy_path.is_file():
722
+ return _result_error(
723
+ "GATE_POLICY_MISSING",
724
+ f"gate policy missing: {gate_policy_path}",
725
+ )
726
+ try:
727
+ json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
728
+ except (OSError, json.JSONDecodeError) as exc:
729
+ return _result_error("GATE_POLICY_INVALID", str(exc))
458
730
  gate_policy_consistent = True
459
- if gate_policy_path.is_file():
460
- try:
461
- json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
462
- except (OSError, json.JSONDecodeError):
463
- gate_policy_consistent = False
464
- else:
465
- gate_policy_consistent = False
466
731
 
467
- # Validate events.ndjson: count phase.end, check for parse errors.
732
+ # Validate events.ndjson: require one matching start/end lifecycle.
468
733
  events_path = change_dir / "events.ndjson"
734
+ phase_start_count = 0
469
735
  phase_end_count = 0
470
736
  phase_end_status: str | None = None
471
737
  events_parse_errors: list[str] = []
@@ -481,10 +747,16 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
481
747
  if not isinstance(event, dict):
482
748
  events_parse_errors.append(f"line {line_no}: not an object")
483
749
  continue
750
+ if event.get("phase") != "plan":
751
+ continue
484
752
  if (
485
- event.get("phase") == "plan"
486
- and event.get("type") == "phase.end"
753
+ str(event.get("run_id") or "") != receipt_run_id
754
+ or event.get("attempt") != receipt_attempt
487
755
  ):
756
+ continue
757
+ if event.get("type") == "phase.start":
758
+ phase_start_count += 1
759
+ elif event.get("type") == "phase.end":
488
760
  phase_end_count += 1
489
761
  phase_end_status = str(event.get("status") or "").upper()
490
762
  else:
@@ -495,15 +767,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
495
767
  "EVENTS_PARSE_ERROR", "; ".join(events_parse_errors[:5])
496
768
  )
497
769
 
770
+ if phase_start_count == 0:
771
+ return _result_error(
772
+ "PHASE_START_MISSING",
773
+ "no matching plan phase.start event found",
774
+ )
775
+ if phase_start_count > 1:
776
+ return _result_error(
777
+ "PHASE_START_DUPLICATE",
778
+ f"found {phase_start_count} matching plan phase.start events",
779
+ )
498
780
  if phase_end_count == 0:
499
781
  return _result_error("PHASE_END_MISSING", "no plan phase.end event found")
500
782
  if phase_end_count > 1:
501
783
  return _result_error(
502
784
  "PHASE_END_DUPLICATE", f"found {phase_end_count} phase.end events"
503
785
  )
504
-
505
- # Receipt consistency: status should be finalized.
506
- receipt_consistent = receipt.get("status") == "finalized"
786
+ if phase_end_status != "OK":
787
+ return _result_error(
788
+ "PHASE_END_NOT_OK",
789
+ f"plan phase.end status is {phase_end_status!r}, expected 'OK'",
790
+ )
507
791
 
508
792
  return {
509
793
  "ok": True,
@@ -511,11 +795,14 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
511
795
  "changeDir": str(change_dir),
512
796
  "changeName": change_name,
513
797
  "artifactsHash": actual_hash,
798
+ "phaseStartCount": phase_start_count,
514
799
  "phaseEndCount": phase_end_count,
515
800
  "phaseEndStatus": phase_end_status,
516
801
  "frontmatter": frontmatter_results,
517
802
  "gatePolicyConsistent": gate_policy_consistent,
518
- "receiptConsistent": receipt_consistent,
803
+ "receiptConsistent": True,
804
+ "taskCount": len(expected_tasks),
805
+ "scenarioCount": len(expected_scenarios),
519
806
  "files": artifact_names,
520
807
  }
521
808
 
@@ -645,6 +932,9 @@ def finalize_plan(
645
932
  validation = validate_staging(staging, change_name)
646
933
  if not validation["ok"]:
647
934
  return validation
935
+ start_validation = _validate_plan_start(change_dir, run_id, attempt)
936
+ if not start_validation["ok"]:
937
+ return start_validation
648
938
 
649
939
  receipt_path = change_dir / "meta" / "plan-finalization.json"
650
940
  lock_path = change_dir / "meta" / "plan-finalize.lock"
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "codex",
5
- "coreHash": "cdb3eeb74bd77050"
5
+ "coreHash": "8673695c7e2a1c19"
6
6
  }
@@ -2,7 +2,7 @@
2
2
  name: harness-apidoc
3
3
  description: 从 Java 后端代码生成结构化前端 API 文档(Controller/VO/ErrorCode)。使用场景:接口文档、前端接口文档、API 文档生成
4
4
  ---
5
- <!-- generated by harness_deploy.py; core=cdb3eeb74bd77050; overlay=java; agent=codex; do not edit -->
5
+ <!-- generated by harness_deploy.py; core=8673695c7e2a1c19; overlay=java; agent=codex; do not edit -->
6
6
  # harness-apidoc — 前端接口文档(Java overlay)
7
7
 
8
8
  ## Purpose
@@ -69,12 +69,12 @@ description: 从 Java 后端代码生成结构化前端 API 文档(Controller/
69
69
  `events.ndjson` 为唯一事实源(schema_version 3,兼容读取 v1/v2;`note` 承载人类可读摘要);`logs/execution-log.md` 由 `harness_events.py` 渲染,**禁止用 Write/Edit 直接维护**。直接修改的内容会在 `phase.end` 或 finalize 时被完整重建覆盖,属于数据丢失;需要保留的详情必须进入事件 `note`。结构 → [[../protocols/report-pipeline-protocol.md|report-pipeline-protocol]]
70
70
 
71
71
  ```powershell
72
- python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --note "<触发指令>"
72
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --run-id <phase-run-id> --attempt <n> --note "<触发指令>"
73
73
  ```
74
74
 
75
75
  > **脚本接线**:`harness_events.py append`;`harness_archive.py finalize`;`harness_preflight.py check`;`harness_ledger.py can-reuse`;`harness_service.py ensure/stop`(须 `--files`/`serviceStart.inputFiles`)。JSON 输出按 D13 护栏解读。
76
76
 
77
77
  > **Task 4 §6.1 写入契约**:普通 `append` = 加锁 -> 追加一行 -> fsync -> 解锁,**不 load 历史、不渲染**(O(1),跨进程锁 `events.ndjson.lock`,UUID 用完整 `uuid4().hex` 无需去重扫描)。仅 `--type phase.end` append 在追加成功后渲染一次 `execution-log.md`;显式 `harness_events.py render` 随时从完整 events 重建;`harness_archive.py finalize` 在 collect 前强制 render 一次。高频 command append 期间 log 可能滞后,phase 边界保持最新。
78
78
 
79
- 每次阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`;重试同一阶段时增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
79
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
80
80
  > 片段:logging · phase=`apidoc`
@@ -3,7 +3,7 @@ name: harness-archive
3
3
  description: 归档所有变更产出(计划/测试报告/审查/SQL/API文档)到
4
4
  .harness/archive/,含归档元数据和可视化最终总结。使用场景:归档、完成归档、收尾、打包产出物
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=cdb3eeb74bd77050; overlay=java; agent=codex; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=8673695c7e2a1c19; overlay=java; agent=codex; do not edit -->
7
7
  # harness-archive — 变更归档
8
8
 
9
9
  ## Purpose
@@ -208,12 +208,12 @@ git 命令通过 `powershell.exe -Command "..."` 执行;archive-meta.md 和 fi
208
208
  `events.ndjson` 为唯一事实源(schema_version 3,兼容读取 v1/v2;`note` 承载人类可读摘要);`logs/execution-log.md` 由 `harness_events.py` 渲染,**禁止用 Write/Edit 直接维护**。直接修改的内容会在 `phase.end` 或 finalize 时被完整重建覆盖,属于数据丢失;需要保留的详情必须进入事件 `note`。结构 → [[../protocols/report-pipeline-protocol.md|report-pipeline-protocol]]
209
209
 
210
210
  ```powershell
211
- python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --note "<触发指令>"
211
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --run-id <phase-run-id> --attempt <n> --note "<触发指令>"
212
212
  ```
213
213
 
214
214
  > **脚本接线**:`harness_events.py append`;`harness_archive.py finalize`;`harness_preflight.py check`;`harness_ledger.py can-reuse`;`harness_service.py ensure/stop`(须 `--files`/`serviceStart.inputFiles`)。JSON 输出按 D13 护栏解读。
215
215
 
216
216
  > **Task 4 §6.1 写入契约**:普通 `append` = 加锁 -> 追加一行 -> fsync -> 解锁,**不 load 历史、不渲染**(O(1),跨进程锁 `events.ndjson.lock`,UUID 用完整 `uuid4().hex` 无需去重扫描)。仅 `--type phase.end` append 在追加成功后渲染一次 `execution-log.md`;显式 `harness_events.py render` 随时从完整 events 重建;`harness_archive.py finalize` 在 collect 前强制 render 一次。高频 command append 期间 log 可能滞后,phase 边界保持最新。
217
217
 
218
- 每次阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`;重试同一阶段时增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
218
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
219
219
  > 片段:logging · phase=`archive`
@@ -3,7 +3,7 @@ name: harness-codebase-map
3
3
  description: 分析现有代码库并生成 .harness/codebase/map/
4
4
  下的结构化代码库地图。用于陌生项目理解、重大改造前梳理、harness-sync 提示 map 过期后的人工刷新。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=cdb3eeb74bd77050; overlay=java; agent=codex; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=8673695c7e2a1c19; overlay=java; agent=codex; do not edit -->
7
7
  # harness-codebase-map — 代码库地图
8
8
 
9
9
  ## Purpose
@@ -89,12 +89,12 @@ description: 分析现有代码库并生成 .harness/codebase/map/
89
89
  `events.ndjson` 为唯一事实源(schema_version 3,兼容读取 v1/v2;`note` 承载人类可读摘要);`logs/execution-log.md` 由 `harness_events.py` 渲染,**禁止用 Write/Edit 直接维护**。直接修改的内容会在 `phase.end` 或 finalize 时被完整重建覆盖,属于数据丢失;需要保留的详情必须进入事件 `note`。结构 → [[../protocols/report-pipeline-protocol.md|report-pipeline-protocol]]
90
90
 
91
91
  ```powershell
92
- python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --note "<触发指令>"
92
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --run-id <phase-run-id> --attempt <n> --note "<触发指令>"
93
93
  ```
94
94
 
95
95
  > **脚本接线**:`harness_events.py append`;`harness_archive.py finalize`;`harness_preflight.py check`;`harness_ledger.py can-reuse`;`harness_service.py ensure/stop`(须 `--files`/`serviceStart.inputFiles`)。JSON 输出按 D13 护栏解读。
96
96
 
97
97
  > **Task 4 §6.1 写入契约**:普通 `append` = 加锁 -> 追加一行 -> fsync -> 解锁,**不 load 历史、不渲染**(O(1),跨进程锁 `events.ndjson.lock`,UUID 用完整 `uuid4().hex` 无需去重扫描)。仅 `--type phase.end` append 在追加成功后渲染一次 `execution-log.md`;显式 `harness_events.py render` 随时从完整 events 重建;`harness_archive.py finalize` 在 collect 前强制 render 一次。高频 command append 期间 log 可能滞后,phase 边界保持最新。
98
98
 
99
- 每次阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`;重试同一阶段时增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
99
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
100
100
  > 片段:logging · phase=`codebase-map`;默认写 reports,有未归档变更时另写 change events
@@ -3,7 +3,7 @@ name: harness-knowledge-ingest
3
3
  description: 从 .harness/archive 归档整理、同步和维护项目知识索引。适用场景:ingest knowledge、sync
4
4
  knowledge、rebuild knowledge index、promote knowledge、确认知识条目、检查知识库是否过期。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=cdb3eeb74bd77050; overlay=java; agent=codex; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=8673695c7e2a1c19; overlay=java; agent=codex; do not edit -->
7
7
  # harness-knowledge-ingest
8
8
 
9
9
  从 `.harness/archive/**/reports/final/summary-data.json` 抽取项目知识,生成并维护本地 `.harness/knowledge/` 索引。
@@ -238,12 +238,12 @@ powershell.exe -Command "python '<skill-dir>\scripts\harness_knowledge.py' sync
238
238
  `events.ndjson` 为唯一事实源(schema_version 3,兼容读取 v1/v2;`note` 承载人类可读摘要);`logs/execution-log.md` 由 `harness_events.py` 渲染,**禁止用 Write/Edit 直接维护**。直接修改的内容会在 `phase.end` 或 finalize 时被完整重建覆盖,属于数据丢失;需要保留的详情必须进入事件 `note`。结构 → [[../protocols/report-pipeline-protocol.md|report-pipeline-protocol]]
239
239
 
240
240
  ```powershell
241
- python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --note "<触发指令>"
241
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type phase.start --run-id <phase-run-id> --attempt <n> --note "<触发指令>"
242
242
  ```
243
243
 
244
244
  > **脚本接线**:`harness_events.py append`;`harness_archive.py finalize`;`harness_preflight.py check`;`harness_ledger.py can-reuse`;`harness_service.py ensure/stop`(须 `--files`/`serviceStart.inputFiles`)。JSON 输出按 D13 护栏解读。
245
245
 
246
246
  > **Task 4 §6.1 写入契约**:普通 `append` = 加锁 -> 追加一行 -> fsync -> 解锁,**不 load 历史、不渲染**(O(1),跨进程锁 `events.ndjson.lock`,UUID 用完整 `uuid4().hex` 无需去重扫描)。仅 `--type phase.end` append 在追加成功后渲染一次 `execution-log.md`;显式 `harness_events.py render` 随时从完整 events 重建;`harness_archive.py finalize` 在 collect 前强制 render 一次。高频 command append 期间 log 可能滞后,phase 边界保持最新。
247
247
 
248
- 每次阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`;重试同一阶段时增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
248
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
249
249
  > 片段:logging · phase=`knowledge-ingest` · 默认控制台报告;变更上下文写 phase/decision/issue/artifact