@hunter-harness/workflow-harness 0.2.80 → 0.2.82

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 (154) 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 +18 -4
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +13 -3
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +13 -3
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +13 -3
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +13 -3
  7. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-push/SKILL.md +6 -2
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +13 -3
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +13 -3
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +13 -3
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +13 -3
  14. package/harness/bundles/general/claude-code/scripts/harness_archive.py +173 -20
  15. package/harness/bundles/general/claude-code/scripts/harness_context.py +77 -3
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +35 -2
  17. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +6 -1
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +18 -4
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +13 -3
  21. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +13 -3
  22. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +13 -3
  23. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +13 -3
  24. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +6 -2
  26. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +13 -3
  27. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +13 -3
  28. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +13 -3
  29. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +13 -3
  31. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +173 -20
  32. package/harness/bundles/general/codebuddy/scripts/harness_context.py +77 -3
  33. package/harness/bundles/general/codebuddy/scripts/harness_events.py +35 -2
  34. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +6 -1
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +18 -4
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +13 -3
  38. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +13 -3
  39. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +13 -3
  40. package/harness/bundles/general/codex/harness-plan/SKILL.md +13 -3
  41. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-push/SKILL.md +6 -2
  43. package/harness/bundles/general/codex/harness-review/SKILL.md +13 -3
  44. package/harness/bundles/general/codex/harness-run/SKILL.md +13 -3
  45. package/harness/bundles/general/codex/harness-submit/SKILL.md +13 -3
  46. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-test/SKILL.md +13 -3
  48. package/harness/bundles/general/codex/scripts/harness_archive.py +173 -20
  49. package/harness/bundles/general/codex/scripts/harness_context.py +77 -3
  50. package/harness/bundles/general/codex/scripts/harness_events.py +35 -2
  51. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +6 -1
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +18 -4
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +13 -3
  55. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +13 -3
  56. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +13 -3
  57. package/harness/bundles/general/cursor/harness-plan/SKILL.md +13 -3
  58. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-push/SKILL.md +6 -2
  60. package/harness/bundles/general/cursor/harness-review/SKILL.md +13 -3
  61. package/harness/bundles/general/cursor/harness-run/SKILL.md +13 -3
  62. package/harness/bundles/general/cursor/harness-submit/SKILL.md +13 -3
  63. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-test/SKILL.md +13 -3
  65. package/harness/bundles/general/cursor/scripts/harness_archive.py +173 -20
  66. package/harness/bundles/general/cursor/scripts/harness_context.py +77 -3
  67. package/harness/bundles/general/cursor/scripts/harness_events.py +35 -2
  68. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +6 -1
  69. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  70. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +13 -3
  71. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +18 -4
  72. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +13 -3
  73. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +13 -3
  74. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +13 -3
  75. package/harness/bundles/java/claude-code/harness-package/SKILL.md +13 -3
  76. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +13 -3
  77. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  78. package/harness/bundles/java/claude-code/harness-push/SKILL.md +6 -2
  79. package/harness/bundles/java/claude-code/harness-review/SKILL.md +13 -3
  80. package/harness/bundles/java/claude-code/harness-run/SKILL.md +13 -3
  81. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +13 -3
  82. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-test/SKILL.md +13 -3
  84. package/harness/bundles/java/claude-code/scripts/harness_archive.py +173 -20
  85. package/harness/bundles/java/claude-code/scripts/harness_context.py +77 -3
  86. package/harness/bundles/java/claude-code/scripts/harness_events.py +35 -2
  87. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +6 -1
  88. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  89. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +13 -3
  90. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +18 -4
  91. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +13 -3
  92. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +13 -3
  93. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +13 -3
  94. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +13 -3
  95. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +13 -3
  96. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  97. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +6 -2
  98. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +13 -3
  99. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +13 -3
  100. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +13 -3
  101. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +13 -3
  103. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +173 -20
  104. package/harness/bundles/java/codebuddy/scripts/harness_context.py +77 -3
  105. package/harness/bundles/java/codebuddy/scripts/harness_events.py +35 -2
  106. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +6 -1
  107. package/harness/bundles/java/codex/.harness-build.json +1 -1
  108. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +13 -3
  109. package/harness/bundles/java/codex/harness-archive/SKILL.md +18 -4
  110. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +13 -3
  111. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +13 -3
  112. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +13 -3
  113. package/harness/bundles/java/codex/harness-package/SKILL.md +13 -3
  114. package/harness/bundles/java/codex/harness-plan/SKILL.md +13 -3
  115. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  116. package/harness/bundles/java/codex/harness-push/SKILL.md +6 -2
  117. package/harness/bundles/java/codex/harness-review/SKILL.md +13 -3
  118. package/harness/bundles/java/codex/harness-run/SKILL.md +13 -3
  119. package/harness/bundles/java/codex/harness-submit/SKILL.md +13 -3
  120. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-test/SKILL.md +13 -3
  122. package/harness/bundles/java/codex/scripts/harness_archive.py +173 -20
  123. package/harness/bundles/java/codex/scripts/harness_context.py +77 -3
  124. package/harness/bundles/java/codex/scripts/harness_events.py +35 -2
  125. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +6 -1
  126. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  127. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +13 -3
  128. package/harness/bundles/java/cursor/harness-archive/SKILL.md +18 -4
  129. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +13 -3
  130. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +13 -3
  131. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +13 -3
  132. package/harness/bundles/java/cursor/harness-package/SKILL.md +13 -3
  133. package/harness/bundles/java/cursor/harness-plan/SKILL.md +13 -3
  134. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  135. package/harness/bundles/java/cursor/harness-push/SKILL.md +6 -2
  136. package/harness/bundles/java/cursor/harness-review/SKILL.md +13 -3
  137. package/harness/bundles/java/cursor/harness-run/SKILL.md +13 -3
  138. package/harness/bundles/java/cursor/harness-submit/SKILL.md +13 -3
  139. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-test/SKILL.md +13 -3
  141. package/harness/bundles/java/cursor/scripts/harness_archive.py +173 -20
  142. package/harness/bundles/java/cursor/scripts/harness_context.py +77 -3
  143. package/harness/bundles/java/cursor/scripts/harness_events.py +35 -2
  144. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +6 -1
  145. package/harness/manifests/general/claude-code.json +19 -19
  146. package/harness/manifests/general/codebuddy.json +19 -19
  147. package/harness/manifests/general/codex.json +19 -19
  148. package/harness/manifests/general/cursor.json +19 -19
  149. package/harness/manifests/java/claude-code.json +21 -21
  150. package/harness/manifests/java/codebuddy.json +21 -21
  151. package/harness/manifests/java/codex.json +21 -21
  152. package/harness/manifests/java/cursor.json +21 -21
  153. package/hunter-workflow-family.json +5 -5
  154. package/package.json +1 -1
@@ -3,7 +3,7 @@ name: harness-test
3
3
  description: 测试执行:读取场景表,执行单元测试+API接口测试+数据兼容验证,输出测试报告。仅当用户显式调用 /harness-test
4
4
  时使用;不得在 run 结束后自动接续执行。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=b45619980b5b945d; overlay=none; agent=codex; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=2676178fa6f219fd; overlay=none; agent=codex; do not edit -->
7
7
  # harness-test — 测试执行
8
8
 
9
9
  ## Purpose
@@ -219,13 +219,23 @@ python <skills-root>/scripts/harness_test_guard.py record --project . --change-d
219
219
 
220
220
  `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]]
221
221
 
222
+ **`phase.start` 由 `harness_gate.py begin` 写,不要再手工追加一次。** 两条同 `run-id` 的
223
+ `phase.start` 会让 `plan finalize` 以 `PHASE_START_DUPLICATE` 卡死,而且手工那次会先触发
224
+ auto-seal、把正在开始的 attempt 封成 `RECOVERED`。要补触发指令说明就带 `--note` 跑 `gate begin`。
225
+ (重复追加现已按 `(phase, run-id)` 判为幂等 no-op,但依赖它不如不写。)
226
+
222
227
  ```powershell
223
- 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 "<触发指令>"
228
+ # 阶段开始:gate begin 负责,note 在这里给
229
+ python <skills-root>/scripts/harness_gate.py begin --change-dir ".harness/changes/<change-name>" --phase <phase> --note "<触发指令>"
230
+ # 阶段中的其他事件才用 append
231
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type <command|issue|verification> --run-id <phase-run-id> --note "<摘要>"
224
232
  ```
225
233
 
226
234
  > **脚本接线**:`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 护栏解读。
227
235
 
228
236
  > **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 边界保持最新。
229
237
 
230
- 每个阶段的 `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` 环境变量统一注入。
238
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。**`attempt` 按 phase 全局递增,不是按 run-id**:一个 run-id 只绑定一个 attempt,重试必须「新 run-id + 下一个 attempt」两者同时换,只换其一会撞 `EVENT_ATTEMPT_CONFLICT` `PHASE_ALREADY_CLOSED`。已发布的 plan 用 `harness_plan_finalize.py republish` 自动分配两者。
239
+
240
+ 阶段跑得久(plan/run 常见)时用 `harness_context.py renew --project . --change <cn> --executor <tool>` 续租;租约到期本身不再阻断 `close`(同一 owner 的过期租约不构成冲突,收据里记 `leaseLapsed`),但续租能让 `view` 的状态如实反映在跑。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
231
241
  > 片段:logging · phase=`test` · 事件:phase/command/verification/decision/issue/artifact
@@ -9041,7 +9041,27 @@ def collect_archive_core_paths(project_root: Path, archive_dir: Path) -> list[st
9041
9041
  return [source.relative_to(root).as_posix() for source, *_ in file_specs]
9042
9042
 
9043
9043
 
9044
- def _archive_source_identity(project_root: Path) -> dict[str, str | None]:
9044
+ def _archive_recorded_commit(archive_dir: Path | None) -> str | None:
9045
+ """The commit the archive itself recorded as its endpoint, if any."""
9046
+ if archive_dir is None:
9047
+ return None
9048
+ try:
9049
+ summary = read_json(archive_dir / "reports" / "final" / "summary-data.json")
9050
+ except (OSError, ValueError, json.JSONDecodeError):
9051
+ return None
9052
+ if not isinstance(summary, dict):
9053
+ return None
9054
+ for key in ("finalCommit", "final_commit", "currentHead", "headCommit"):
9055
+ value = summary.get(key)
9056
+ if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}", value.strip()):
9057
+ return value.strip()
9058
+ return None
9059
+
9060
+
9061
+ def _archive_source_identity(
9062
+ project_root: Path,
9063
+ archive_dir: Path | None = None,
9064
+ ) -> dict[str, str | None]:
9045
9065
  def git_value(*args: str) -> str | None:
9046
9066
  try:
9047
9067
  completed = subprocess.run(
@@ -9060,6 +9080,15 @@ def _archive_source_identity(project_root: Path) -> dict[str, str | None]:
9060
9080
  value = completed.stdout.strip()
9061
9081
  return value if completed.returncode == 0 and value else None
9062
9082
 
9083
+ # Prefer the archive's own endpoint over live HEAD so the package stays a
9084
+ # pure function of the sealed directory. Falling back to HEAD keeps older
9085
+ # archives that never recorded a commit working exactly as before.
9086
+ recorded = _archive_recorded_commit(archive_dir)
9087
+ if recorded is not None:
9088
+ return {
9089
+ "commit": recorded,
9090
+ "tree": git_value("rev-parse", f"{recorded}^{{tree}}"),
9091
+ }
9063
9092
  return {
9064
9093
  "commit": git_value("rev-parse", "HEAD"),
9065
9094
  "tree": git_value("rev-parse", "HEAD^{tree}"),
@@ -9244,7 +9273,7 @@ def build_archive_package(
9244
9273
  "profile": "core-v1",
9245
9274
  "change_key": change_key,
9246
9275
  "created_at": _archive_created_at(summary_path),
9247
- "source": _archive_source_identity(project_root),
9276
+ "source": _archive_source_identity(project_root, archive_dir),
9248
9277
  "files": entries,
9249
9278
  }
9250
9279
  manifest_bytes = (
@@ -9482,35 +9511,83 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9482
9511
  }
9483
9512
 
9484
9513
 
9514
+ def load_retained_package(project_root: Path, change_key: str) -> dict[str, Any] | None:
9515
+ """A package kept on disk for retry, described by its own pending receipt.
9516
+
9517
+ Retrying is only meaningful against the bytes the server already saw. A
9518
+ rebuild is a different package: the archive directory and the harness both
9519
+ move on, so "retry the same ZIP" has to mean exactly that.
9520
+ """
9521
+ packages = project_root / ".harness" / "state" / "local" / "archive-packages"
9522
+ zip_path = packages / f"{change_key}.zip"
9523
+ receipt_path = packages / f"{change_key}.upload.json"
9524
+ if not zip_path.is_file() or not receipt_path.is_file():
9525
+ return None
9526
+ try:
9527
+ receipt = read_json(receipt_path)
9528
+ except (OSError, ValueError, json.JSONDecodeError):
9529
+ return None
9530
+ if not isinstance(receipt, dict):
9531
+ return None
9532
+ recorded = str(receipt.get("packageSha256") or "")
9533
+ actual = "sha256:" + hashlib.sha256(zip_path.read_bytes()).hexdigest()
9534
+ if recorded and recorded != actual:
9535
+ # The receipt no longer describes the file beside it; refuse to guess.
9536
+ return None
9537
+ return {
9538
+ "schemaVersion": 1,
9539
+ "profile": "core-v1",
9540
+ "changeKey": change_key,
9541
+ "packagePath": str(zip_path),
9542
+ "packageSha256": actual,
9543
+ "manifestSha256": receipt.get("manifestSha256"),
9544
+ "fileCount": receipt.get("fileCount"),
9545
+ "sizeBytes": zip_path.stat().st_size,
9546
+ "paths": list(receipt.get("paths") or []),
9547
+ "knowledgeCandidateCount": receipt.get("knowledgeCandidateCount"),
9548
+ "reused": True,
9549
+ }
9550
+
9551
+
9485
9552
  def auto_push_archive_core(
9486
9553
  project_root: Path,
9487
9554
  archive_dir: Path,
9488
9555
  *,
9489
9556
  change_key: str | None = None,
9490
9557
  extra_entries: dict[str, tuple[str, str, bytes]] | None = None,
9558
+ reuse_retained_package: bool = False,
9491
9559
  ) -> dict[str, Any]:
9492
9560
  """Build and upload one deterministic core ZIP after finalize.
9493
9561
 
9494
9562
  The ZIP and its per-change receipt are always created before auth is
9495
9563
  evaluated. Failures become warnings so archive success is never rolled
9496
9564
  back by missing credentials or a remote outage.
9565
+
9566
+ With ``reuse_retained_package`` a package already kept for retry is uploaded
9567
+ as-is instead of being rebuilt, which is what the retry messages promise.
9497
9568
  """
9498
9569
  project_root = project_root.resolve()
9499
9570
  effective_change_key = change_key or archive_dir.name
9500
- try:
9501
- package = build_archive_package(
9502
- project_root,
9503
- archive_dir,
9504
- effective_change_key,
9505
- extra_entries=extra_entries,
9506
- )
9507
- except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
9508
- return {
9509
- "skipped": False,
9510
- "ok": False,
9511
- "reasonCode": "ARCHIVE_PACKAGE_BUILD_FAILED",
9512
- "warning": f"核心归档 ZIP 生成失败:{exc}",
9513
- }
9571
+ package = (
9572
+ load_retained_package(project_root, effective_change_key)
9573
+ if reuse_retained_package
9574
+ else None
9575
+ )
9576
+ if package is None:
9577
+ try:
9578
+ package = build_archive_package(
9579
+ project_root,
9580
+ archive_dir,
9581
+ effective_change_key,
9582
+ extra_entries=extra_entries,
9583
+ )
9584
+ except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
9585
+ return {
9586
+ "skipped": False,
9587
+ "ok": False,
9588
+ "reasonCode": "ARCHIVE_PACKAGE_BUILD_FAILED",
9589
+ "warning": f"核心归档 ZIP 生成失败:{exc}",
9590
+ }
9514
9591
  core_paths = list(package["paths"])
9515
9592
  package_path = Path(str(package["packagePath"]))
9516
9593
  try:
@@ -10346,6 +10423,7 @@ def cmd_republish(
10346
10423
  project_root: Path | None,
10347
10424
  dry_run: bool,
10348
10425
  inject_knowledge: bool = True,
10426
+ retry_retained: bool = False,
10349
10427
  ) -> tuple[int, dict[str, Any]]:
10350
10428
  """Rebuild and re-upload the core ZIP for an already sealed archive.
10351
10429
 
@@ -10405,6 +10483,70 @@ def cmd_republish(
10405
10483
  extra_entries = {}
10406
10484
  payload["knowledgeCandidatesInjected"] = "candidates/knowledge.json" in extra_entries
10407
10485
 
10486
+ if retry_retained:
10487
+ retained = load_retained_package(root, change_key)
10488
+ if retained is None:
10489
+ payload.update({
10490
+ "ok": False,
10491
+ "reasonCode": "ARCHIVE_RETAINED_PACKAGE_UNAVAILABLE",
10492
+ "error": (
10493
+ "没有可重试的留存包:需要 "
10494
+ f".harness/state/local/archive-packages/{change_key}.zip 与同名 "
10495
+ ".upload.json 同时存在,且回执记录的 packageSha256 与 ZIP 字节一致。"
10496
+ ),
10497
+ "nextAction": "去掉 --retry-retained 可按封存目录重建一个新包。",
10498
+ })
10499
+ return 1, payload
10500
+ payload["retainedPackage"] = {
10501
+ "packagePath": retained["packagePath"],
10502
+ "packageSha256": retained["packageSha256"],
10503
+ "fileCount": retained["fileCount"],
10504
+ "sizeBytes": retained["sizeBytes"],
10505
+ }
10506
+ durable = read_durable_archive_receipt(root, change_key)
10507
+ stored_sha = str((durable or {}).get("packageSha256") or "")
10508
+ if (
10509
+ durable is not None
10510
+ and str(durable.get("archiveStatus") or "") == "durable"
10511
+ and stored_sha
10512
+ and stored_sha != retained["packageSha256"]
10513
+ ):
10514
+ # Identical bytes stay allowed below — re-uploading the same package
10515
+ # is the documented way to retry a failed knowledge index. Different
10516
+ # bytes are refused by the server, so do not spend the upload.
10517
+ payload.update({
10518
+ "ok": False,
10519
+ "reasonCode": "ARCHIVE_REMOTE_IMMUTABLE_CONFLICT",
10520
+ "remoteReceipt": {
10521
+ "archiveId": durable.get("archiveId"),
10522
+ "packageSha256": stored_sha,
10523
+ },
10524
+ "error": (
10525
+ "留存包与远端已发布的包字节不同,服务端对同一 change key 只保存一个"
10526
+ "不可变包,不接受替换。"
10527
+ ),
10528
+ "nextAction": (
10529
+ "该留存包是一次失败尝试的残留,不是已发布的那一份;"
10530
+ "确认无用后可删除 .zip 与 .upload.json。"
10531
+ ),
10532
+ })
10533
+ return 1, payload
10534
+ if dry_run:
10535
+ payload.update({"ok": True, "reasonCode": "ARCHIVE_RETAINED_PACKAGE_PREVIEW"})
10536
+ return 0, payload
10537
+ result = auto_push_archive_core(
10538
+ root,
10539
+ archive_dir,
10540
+ change_key=change_key,
10541
+ reuse_retained_package=True,
10542
+ )
10543
+ payload["push"] = result
10544
+ payload["ok"] = bool(result.get("ok"))
10545
+ payload["reasonCode"] = result.get("reasonCode")
10546
+ if result.get("warning"):
10547
+ payload.setdefault("warnings", []).append(str(result["warning"]))
10548
+ return 0 if payload["ok"] else 1, payload
10549
+
10408
10550
  # Build once, into a preview path, so the bytes can be compared against any
10409
10551
  # durable receipt before an upload is attempted. The builder refuses to write
10410
10552
  # outside the project, so the preview lands beside the real packages under a
@@ -10479,8 +10621,8 @@ def cmd_republish(
10479
10621
  f"{package['knowledgeCandidateCount']} 条知识候选"
10480
10622
  "(原包上传时还没有 candidates/knowledge.json),因此字节必然不同——"
10481
10623
  "已发布归档无法从客户端追加知识条目,需要平台侧提供重新索引或归档版本化能力。"
10482
- "若只是想重试同一个包(例如知识索引失败),加 --no-knowledge-injection "
10483
- "可重建与封存目录完全一致的字节。"
10624
+ "若只是想重试同一个包(例如知识索引失败),用 --retry-retained "
10625
+ "上传盘上留存的原包字节;重建得不到已发布的字节。"
10484
10626
  if payload.get("knowledgeCandidatesInjected")
10485
10627
  else "本地归档内容与已上传的包不一致,请先确认哪一份才是应保留的事实。"
10486
10628
  )
@@ -10513,6 +10655,7 @@ def cmd_republish_cli(args: argparse.Namespace) -> int:
10513
10655
  project_root=resolve_path(args.project) if args.project else None,
10514
10656
  dry_run=bool(args.dry_run),
10515
10657
  inject_knowledge=not bool(args.no_knowledge_injection),
10658
+ retry_retained=bool(args.retry_retained),
10516
10659
  )
10517
10660
  emit_json(payload)
10518
10661
  return code
@@ -10696,12 +10839,22 @@ def build_parser() -> argparse.ArgumentParser:
10696
10839
  action="store_true",
10697
10840
  help="build the package and report its contents without uploading",
10698
10841
  )
10842
+ p_republish.add_argument(
10843
+ "--retry-retained",
10844
+ action="store_true",
10845
+ help=(
10846
+ "upload the package already kept on disk for retry, byte for byte, "
10847
+ "instead of rebuilding it"
10848
+ ),
10849
+ )
10699
10850
  p_republish.add_argument(
10700
10851
  "--no-knowledge-injection",
10701
10852
  action="store_true",
10702
10853
  help=(
10703
- "rebuild exactly what the sealed archive holds; needed to retry the "
10704
- "same bytes the server already stored"
10854
+ "rebuild without injecting knowledge candidates. Note this does NOT "
10855
+ "reproduce an already-published package: the manifest also binds the "
10856
+ "archive's own commit and the sealed directory may have moved on. To "
10857
+ "retry the exact bytes the server saw, use --retry-retained"
10705
10858
  ),
10706
10859
  )
10707
10860
  p_republish.add_argument("--json", action="store_true", default=True)
@@ -1165,9 +1165,11 @@ def close_transition(
1165
1165
  "holder": lease.get("owner"),
1166
1166
  "leasePhase": lease.get("phase"),
1167
1167
  }
1168
+ # Owner and phase already matched above, so nobody took this lease over: a
1169
+ # takeover rewrites the file and would have produced CONTEXT_LEASE_MISMATCH.
1170
+ # A lapsed deadline therefore proves only that the phase outlived the TTL.
1168
1171
  expiry = _parse_time(lease.get("expiresAt"))
1169
- if expiry is None or _now() >= expiry:
1170
- return {"ok": False, "code": "CONTEXT_LEASE_EXPIRED"}
1172
+ lease_lapsed = expiry is None or _now() >= expiry
1171
1173
 
1172
1174
  previous_hash = transitions[-1].get("receiptHash") if transitions else None
1173
1175
  attempt = (
@@ -1192,6 +1194,12 @@ def close_transition(
1192
1194
  "closedAt": _now().isoformat(),
1193
1195
  "previousReceiptHash": previous_hash,
1194
1196
  }
1197
+ if lease_lapsed:
1198
+ # Recorded, not hidden: the phase ran past its lease without a renewal.
1199
+ receipt["leaseLapsed"] = {
1200
+ "expiresAt": lease.get("expiresAt"),
1201
+ "acquiredAt": lease.get("acquiredAt"),
1202
+ }
1195
1203
  receipt["receiptHash"] = _payload_hash(receipt)
1196
1204
  _append_ndjson(paths["transitions"], receipt)
1197
1205
  paths["lease"].unlink(missing_ok=True)
@@ -1267,7 +1275,11 @@ def _begin_transition_unlocked(
1267
1275
  "legacyBootstrap": True,
1268
1276
  "message": (
1269
1277
  "没有交接凭证。v2 计划应有 committed 的 meta/publication-journals/*.json "
1270
- "作为补录证据;legacy 计划需先完成 plan 阶段的 close"
1278
+ "作为补录证据;legacy 计划需先完成 plan 阶段的 close"
1279
+ "首次进入某阶段(还没有任何 transition 凭证)时不要用 context begin——"
1280
+ "改跑 `harness_gate.py begin --change-dir .harness/changes/<change> "
1281
+ "--phase <phase> --run-id <phase>_<uuid4hex> --note \"<触发指令>\"`,"
1282
+ "它会领取租约并写好 phase.start。"
1271
1283
  ),
1272
1284
  }
1273
1285
  receipt = transitions[-1]
@@ -1458,6 +1470,53 @@ def cancel_prepared_context(
1458
1470
  }
1459
1471
 
1460
1472
 
1473
+ def renew_lease(
1474
+ project: Path,
1475
+ change: str,
1476
+ *,
1477
+ executor: str,
1478
+ ttl_seconds: int = 3600,
1479
+ ) -> dict[str, Any]:
1480
+ """Extend the current phase lease in place.
1481
+
1482
+ Only the recorded owner may renew, and only while no other executor has
1483
+ taken the lease over. Renewal touches nothing but ``expiresAt``, so it is
1484
+ safe to call on a heartbeat during a long phase.
1485
+ """
1486
+ project = project.resolve()
1487
+ try:
1488
+ _, _, state_root = _contract(project, change)
1489
+ except ValueError as exc:
1490
+ return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
1491
+ paths = _paths(state_root)
1492
+ if not paths["lease"].is_file():
1493
+ return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
1494
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1495
+ try:
1496
+ lease = _read_json(paths["lease"])
1497
+ except (OSError, ValueError, json.JSONDecodeError):
1498
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
1499
+ if lease.get("owner") != executor:
1500
+ return {
1501
+ "ok": False,
1502
+ "code": "CONTEXT_LEASE_MISMATCH",
1503
+ "holder": lease.get("owner"),
1504
+ }
1505
+ expiry = _parse_time(lease.get("expiresAt"))
1506
+ lapsed = expiry is None or _now() >= expiry
1507
+ lease["expiresAt"] = (
1508
+ _now() + dt.timedelta(seconds=max(1, int(ttl_seconds)))
1509
+ ).isoformat()
1510
+ lease["renewedAt"] = _now().isoformat()
1511
+ _write_json_atomic(paths["lease"], lease)
1512
+ return {
1513
+ "ok": True,
1514
+ "code": "CONTEXT_LEASE_RENEWED",
1515
+ "lease": lease,
1516
+ "wasLapsed": lapsed,
1517
+ }
1518
+
1519
+
1461
1520
  def context_view(project: Path, change: str) -> dict[str, Any]:
1462
1521
  project = Path(project).resolve()
1463
1522
  try:
@@ -1536,6 +1595,14 @@ def build_parser() -> argparse.ArgumentParser:
1536
1595
  begin.add_argument("--change", required=True)
1537
1596
  begin.add_argument("--phase", required=True)
1538
1597
  begin.add_argument("--executor", required=True)
1598
+ renew = sub.add_parser(
1599
+ "renew", help="extend the current phase lease without touching context"
1600
+ )
1601
+ renew.add_argument("--json", action="store_true")
1602
+ renew.add_argument("--project", required=True, type=Path)
1603
+ renew.add_argument("--change", required=True)
1604
+ renew.add_argument("--executor", required=True)
1605
+ renew.add_argument("--ttl-seconds", type=int, default=3600)
1539
1606
  configure = sub.add_parser("configure-plan")
1540
1607
  configure.add_argument("--json", action="store_true")
1541
1608
  configure.add_argument("--project", required=True, type=Path)
@@ -1588,6 +1655,13 @@ def main(argv: list[str] | None = None) -> int:
1588
1655
  phase=args.phase,
1589
1656
  executor=args.executor,
1590
1657
  )
1658
+ elif args.command == "renew":
1659
+ result = renew_lease(
1660
+ args.project,
1661
+ args.change,
1662
+ executor=args.executor,
1663
+ ttl_seconds=args.ttl_seconds,
1664
+ )
1591
1665
  elif args.command == "configure-plan":
1592
1666
  result = configure_phase_plan(
1593
1667
  args.project,
@@ -1904,6 +1904,7 @@ def cmd_append(args: argparse.Namespace) -> int:
1904
1904
  file=sys.stderr,
1905
1905
  )
1906
1906
  phase_closed = False
1907
+ phase_start_duplicate = False
1907
1908
  projection_error: str | None = None
1908
1909
  attempt_error: str | None = None
1909
1910
  auto_sealed_events: list[dict[str, Any]] = []
@@ -1951,10 +1952,25 @@ def cmd_append(args: argparse.Namespace) -> int:
1951
1952
  except ValueError as exc:
1952
1953
  projection_error = str(exc)
1953
1954
  elif args.type == "phase.start":
1955
+ # Re-appending the same (phase, run_id) is the caller restating a
1956
+ # start that already exists — `gate begin` writes one, and the
1957
+ # logging protocol used to ask for a second. Treat it as a no-op
1958
+ # rather than a duplicate; two identical phase.start events make
1959
+ # `plan finalize` unrecoverable via PHASE_START_DUPLICATE.
1960
+ if run_id is not None and any(
1961
+ item.get("phase") == args.phase
1962
+ and item.get("type") == "phase.start"
1963
+ and item.get("run_id") == run_id
1964
+ for item in existing_events
1965
+ ):
1966
+ phase_start_duplicate = True
1954
1967
  # HH-WF-20260730-001: a new phase.start must not be appended
1955
1968
  # while a prior attempt for the same phase is still open —
1956
1969
  # auto-seal it first so timing never has two open attempts.
1957
- open_attempts = open_attempts_for_phase(existing_events, args.phase)
1970
+ open_attempts = (
1971
+ [] if phase_start_duplicate
1972
+ else open_attempts_for_phase(existing_events, args.phase)
1973
+ )
1958
1974
  if open_attempts:
1959
1975
  inferred_reason = infer_auto_seal_reason(
1960
1976
  open_attempts[-1].get("events") or []
@@ -1964,7 +1980,12 @@ def cmd_append(args: argparse.Namespace) -> int:
1964
1980
  phase=args.phase,
1965
1981
  seal_reason=inferred_reason,
1966
1982
  )
1967
- if not phase_closed and projection_error is None and attempt_error is None:
1983
+ if (
1984
+ not phase_closed
1985
+ and not phase_start_duplicate
1986
+ and projection_error is None
1987
+ and attempt_error is None
1988
+ ):
1968
1989
  for seal_event in auto_sealed_events:
1969
1990
  atomic_append_line(
1970
1991
  path,
@@ -1976,6 +1997,18 @@ def cmd_append(args: argparse.Namespace) -> int:
1976
1997
  )
1977
1998
  except (OSError, TimeoutError, ValueError) as exc:
1978
1999
  return emit_error(f"append failed: {exc}", as_json=as_json)
2000
+ if phase_start_duplicate:
2001
+ if as_json:
2002
+ print(json.dumps({
2003
+ "ok": True,
2004
+ "skipped": True,
2005
+ "reason": "phase-start-already-recorded",
2006
+ "phase": args.phase,
2007
+ "run_id": event.get("run_id"),
2008
+ }, ensure_ascii=False))
2009
+ else:
2010
+ print("ok (phase.start already recorded for this run id)")
2011
+ return 0
1979
2012
  if phase_closed:
1980
2013
  return emit_error(
1981
2014
  "PHASE_ALREADY_CLOSED: refusing a second phase.end for the same "
@@ -735,7 +735,12 @@ def _validate_plan_start(
735
735
  if len(matching) > 1:
736
736
  return _result_error(
737
737
  "PHASE_START_DUPLICATE",
738
- f"found {len(matching)} matching plan phase.start events",
738
+ f"found {len(matching)} matching plan phase.start events for run id "
739
+ f"{run_id}. `harness_gate.py begin` already appends phase.start; a "
740
+ "second manual append of the same run id creates this state. Recover "
741
+ "with a fresh run id and the next attempt number (attempts are global "
742
+ "per phase, not per run id): append phase.start with both, then "
743
+ "finalize with the same pair.",
739
744
  )
740
745
  return {"ok": True, "phaseStartCount": 1}
741
746
 
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "none",
4
4
  "agent": "cursor",
5
- "coreHash": "629c995f68a2e765"
5
+ "coreHash": "bd7ebe189b5b88e3"
6
6
  }
@@ -3,7 +3,7 @@ name: harness-archive
3
3
  description: 归档所有变更产出(计划/测试报告/审查/SQL/API文档)到 .harness/archive/,含归档元数据。仅当用户显式调用
4
4
  /harness-archive 时使用;不得被其他阶段 skill 自动接续触发。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=629c995f68a2e765; overlay=none; agent=cursor; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=bd7ebe189b5b88e3; overlay=none; agent=cursor; do not edit -->
7
7
  # harness-archive — 变更归档
8
8
 
9
9
  ## Purpose
@@ -157,7 +157,11 @@ python <skills-root>/scripts/harness_archive.py republish --change <change-key>
157
157
  (exit 0,不重传);字节不同 → `ARCHIVE_REMOTE_IMMUTABLE_CONFLICT`(exit 1,**不发起上传**)。
158
158
  **已发布归档无法从客户端补知识条目**——注入候选必然改变字节,服务端拒绝替换;要补需要平台侧
159
159
  提供重新索引或归档版本化能力。
160
- 只想重试**同一个包**(例如知识索引失败)时加 `--no-knowledge-injection`,它按封存目录原样重建,字节与已存包一致。
160
+ 只想重试**同一个包**(例如知识索引失败)时用 `--retry-retained`:它上传盘上留存的原包字节,
161
+ 不重建。**重建得不到已发布的字节**——包 manifest 绑定归档自己的提交,封存目录与 harness 本身
162
+ 也都会前进;`--no-knowledge-injection` 只是不注入候选,不等于能复现旧包。
163
+ 留存包与远端字节不同(即那是一次失败尝试的残留)时,`--retry-retained` 同样在本地判出
164
+ `ARCHIVE_REMOTE_IMMUTABLE_CONFLICT` 并拒绝上传。
161
165
 
162
166
  ### 三、文件移动只用内置工具或 PowerShell
163
167
 
@@ -258,13 +262,23 @@ git 命令通过 `powershell.exe -Command "..."` 执行;archive-meta.md 和 su
258
262
 
259
263
  `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]]
260
264
 
265
+ **`phase.start` 由 `harness_gate.py begin` 写,不要再手工追加一次。** 两条同 `run-id` 的
266
+ `phase.start` 会让 `plan finalize` 以 `PHASE_START_DUPLICATE` 卡死,而且手工那次会先触发
267
+ auto-seal、把正在开始的 attempt 封成 `RECOVERED`。要补触发指令说明就带 `--note` 跑 `gate begin`。
268
+ (重复追加现已按 `(phase, run-id)` 判为幂等 no-op,但依赖它不如不写。)
269
+
261
270
  ```powershell
262
- 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 "<触发指令>"
271
+ # 阶段开始:gate begin 负责,note 在这里给
272
+ python <skills-root>/scripts/harness_gate.py begin --change-dir ".harness/changes/<change-name>" --phase <phase> --note "<触发指令>"
273
+ # 阶段中的其他事件才用 append
274
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type <command|issue|verification> --run-id <phase-run-id> --note "<摘要>"
263
275
  ```
264
276
 
265
277
  > **脚本接线**:`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 护栏解读。
266
278
 
267
279
  > **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 边界保持最新。
268
280
 
269
- 每个阶段的 `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` 环境变量统一注入。
281
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。**`attempt` 按 phase 全局递增,不是按 run-id**:一个 run-id 只绑定一个 attempt,重试必须「新 run-id + 下一个 attempt」两者同时换,只换其一会撞 `EVENT_ATTEMPT_CONFLICT` `PHASE_ALREADY_CLOSED`。已发布的 plan 用 `harness_plan_finalize.py republish` 自动分配两者。
282
+
283
+ 阶段跑得久(plan/run 常见)时用 `harness_context.py renew --project . --change <cn> --executor <tool>` 续租;租约到期本身不再阻断 `close`(同一 owner 的过期租约不构成冲突,收据里记 `leaseLapsed`),但续租能让 `view` 的状态如实反映在跑。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
270
284
  > 片段: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=629c995f68a2e765; overlay=none; agent=cursor; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=bd7ebe189b5b88e3; overlay=none; agent=cursor; do not edit -->
7
7
  # harness-codebase-map — 代码库地图
8
8
 
9
9
  ## Purpose
@@ -94,13 +94,23 @@ description: 分析现有代码库并生成 .harness/codebase/map/
94
94
 
95
95
  `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]]
96
96
 
97
+ **`phase.start` 由 `harness_gate.py begin` 写,不要再手工追加一次。** 两条同 `run-id` 的
98
+ `phase.start` 会让 `plan finalize` 以 `PHASE_START_DUPLICATE` 卡死,而且手工那次会先触发
99
+ auto-seal、把正在开始的 attempt 封成 `RECOVERED`。要补触发指令说明就带 `--note` 跑 `gate begin`。
100
+ (重复追加现已按 `(phase, run-id)` 判为幂等 no-op,但依赖它不如不写。)
101
+
97
102
  ```powershell
98
- 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 "<触发指令>"
103
+ # 阶段开始:gate begin 负责,note 在这里给
104
+ python <skills-root>/scripts/harness_gate.py begin --change-dir ".harness/changes/<change-name>" --phase <phase> --note "<触发指令>"
105
+ # 阶段中的其他事件才用 append
106
+ python <skills-root>/scripts/harness_events.py append --change-dir ".harness/changes/<change-name>" --phase <phase> --type <command|issue|verification> --run-id <phase-run-id> --note "<摘要>"
99
107
  ```
100
108
 
101
109
  > **脚本接线**:`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 护栏解读。
102
110
 
103
111
  > **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 边界保持最新。
104
112
 
105
- 每个阶段的 `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` 环境变量统一注入。
113
+ 每个阶段的 `phase.start` 与对应 `phase.end` 必须复用同一 `--run-id` / `--attempt`;阶段结束必须写 `--status OK|WARN|FAIL|BLOCKED`。重试同一阶段时生成新的 run-id 并增加 `--attempt <n>`,不得覆盖或伪装成一次执行。**`attempt` 按 phase 全局递增,不是按 run-id**:一个 run-id 只绑定一个 attempt,重试必须「新 run-id + 下一个 attempt」两者同时换,只换其一会撞 `EVENT_ATTEMPT_CONFLICT` `PHASE_ALREADY_CLOSED`。已发布的 plan 用 `harness_plan_finalize.py republish` 自动分配两者。
114
+
115
+ 阶段跑得久(plan/run 常见)时用 `harness_context.py renew --project . --change <cn> --executor <tool>` 续租;租约到期本身不再阻断 `close`(同一 owner 的过期租约不构成冲突,收据里记 `leaseLapsed`),但续租能让 `view` 的状态如实反映在跑。跨工具继续执行时写 `--executor-tool <codex|claude-code|codebuddy|cursor>`,并在接棒事件写 `--handoff-from-tool` / `--handoff-reason`;也可由 `HUNTER_HARNESS_TOOL/AGENT/MODEL/RUN_ID` 环境变量统一注入。
106
116
  > 片段:logging · phase=`codebase-map`;默认写 reports,有未归档变更时另写 change events