@hunter-harness/workflow-harness 0.2.29 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) 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 +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +28 -51
  12. package/harness/bundles/general/claude-code/harness-sync/reference.md +89 -142
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/scripts/harness_change.py +189 -0
  15. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  16. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  17. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  18. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  19. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  20. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +30 -51
  26. package/harness/bundles/general/codebuddy/harness-sync/reference.md +89 -142
  27. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/scripts/harness_change.py +189 -0
  29. package/harness/bundles/general/codex/.harness-build.json +1 -1
  30. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  31. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  32. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  33. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  34. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  35. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  36. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-sync/SKILL.md +30 -51
  40. package/harness/bundles/general/codex/harness-sync/reference.md +89 -142
  41. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/scripts/harness_change.py +189 -0
  43. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  44. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  45. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  46. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  47. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  48. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  49. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  50. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  51. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  52. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  53. package/harness/bundles/general/cursor/harness-sync/SKILL.md +30 -51
  54. package/harness/bundles/general/cursor/harness-sync/reference.md +89 -142
  55. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/scripts/harness_change.py +189 -0
  57. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  58. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  59. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  60. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  61. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  62. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  63. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  64. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  65. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  66. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  67. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  68. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  69. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +28 -51
  70. package/harness/bundles/java/claude-code/harness-sync/reference.md +89 -142
  71. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  72. package/harness/bundles/java/claude-code/scripts/harness_change.py +189 -0
  73. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  74. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  75. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  77. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  78. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  79. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  80. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  81. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  82. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  83. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  84. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  85. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +30 -51
  86. package/harness/bundles/java/codebuddy/harness-sync/reference.md +89 -142
  87. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  88. package/harness/bundles/java/codebuddy/scripts/harness_change.py +189 -0
  89. package/harness/bundles/java/codex/.harness-build.json +1 -1
  90. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  95. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  96. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  97. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  98. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  99. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  100. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  101. package/harness/bundles/java/codex/harness-sync/SKILL.md +30 -51
  102. package/harness/bundles/java/codex/harness-sync/reference.md +89 -142
  103. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  104. package/harness/bundles/java/codex/scripts/harness_change.py +189 -0
  105. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  106. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  107. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  108. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  109. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  110. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  111. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  112. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  113. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  114. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  115. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  116. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  117. package/harness/bundles/java/cursor/harness-sync/SKILL.md +30 -51
  118. package/harness/bundles/java/cursor/harness-sync/reference.md +89 -142
  119. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  120. package/harness/bundles/java/cursor/scripts/harness_change.py +189 -0
  121. package/harness/manifests/general/claude-code.json +24 -15
  122. package/harness/manifests/general/codebuddy.json +24 -15
  123. package/harness/manifests/general/codex.json +24 -15
  124. package/harness/manifests/general/cursor.json +24 -15
  125. package/harness/manifests/java/claude-code.json +26 -17
  126. package/harness/manifests/java/codebuddy.json +26 -17
  127. package/harness/manifests/java/codex.json +26 -17
  128. package/harness/manifests/java/cursor.json +26 -17
  129. package/hunter-workflow-family.json +18 -2
  130. package/package.json +1 -1
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "none",
4
4
  "agent": "claude-code",
5
- "coreHash": "9a2ab44ad7a34065"
5
+ "coreHash": "370a50726f655b0c"
6
6
  }
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=9a2ab44ad7a34065; overlay=none; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=370a50726f655b0c; overlay=none; agent=claude-code; do not edit -->
24
24
  # harness-archive — 变更归档
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=9a2ab44ad7a34065; overlay=none; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=370a50726f655b0c; overlay=none; agent=claude-code; do not edit -->
24
24
  # harness-codebase-map — 代码库地图
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=9a2ab44ad7a34065; overlay=none; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=370a50726f655b0c; overlay=none; agent=claude-code; do not edit -->
24
24
  # harness-knowledge-ingest
25
25
 
26
26
  从 `.harness/archive/**/reports/final/summary-data.json` 抽取项目知识,生成并维护本地 `.harness/knowledge/` 索引。
@@ -19,6 +19,7 @@ import shutil
19
19
  import sqlite3
20
20
  import subprocess
21
21
  import sys
22
+ import time
22
23
  from difflib import SequenceMatcher
23
24
  from pathlib import Path
24
25
  from typing import Any
@@ -40,6 +41,14 @@ ENTRY_TYPES = {
40
41
  "api-contract",
41
42
  }
42
43
 
44
+ ENTRY_ID_SCHEMA_VERSION = 2
45
+ ARCHIVE_CACHE_SCHEMA_VERSION = 2
46
+ EXTRACTOR_SCHEMA_VERSION = 2
47
+ _GIT_REPO_CACHE: dict[str, bool] = {}
48
+ _GIT_COMMIT_CACHE: dict[tuple[str, str], bool] = {}
49
+ _GIT_DIFF_CACHE: dict[tuple[str, str, tuple[str, ...]], list[str] | None] = {}
50
+ _GIT_SUBPROCESS_COUNT = 0
51
+
43
52
  DEFAULT_AUTO_KNOWLEDGE_CONFIG = {
44
53
  "autoPromote": {
45
54
  "enabled": True,
@@ -303,16 +312,28 @@ def git_head(project: Path) -> str | None:
303
312
 
304
313
 
305
314
  def is_git_repo(project: Path) -> bool:
315
+ key = str(project.resolve())
316
+ if key in _GIT_REPO_CACHE:
317
+ return _GIT_REPO_CACHE[key]
306
318
  result = run_git(project, ["rev-parse", "--is-inside-work-tree"])
307
- return result.returncode == 0 and result.stdout.strip() == "true"
319
+ value = result.returncode == 0 and result.stdout.strip() == "true"
320
+ _GIT_REPO_CACHE[key] = value
321
+ return value
308
322
 
309
323
 
310
324
  def git_commit_exists(project: Path, commit: str) -> bool:
325
+ key = (str(project.resolve()), commit)
326
+ if key in _GIT_COMMIT_CACHE:
327
+ return _GIT_COMMIT_CACHE[key]
311
328
  result = run_git(project, ["cat-file", "-e", f"{commit}^{{commit}}"])
312
- return result.returncode == 0
329
+ value = result.returncode == 0
330
+ _GIT_COMMIT_CACHE[key] = value
331
+ return value
313
332
 
314
333
 
315
334
  def run_git(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
335
+ global _GIT_SUBPROCESS_COUNT
336
+ _GIT_SUBPROCESS_COUNT += 1
316
337
  try:
317
338
  return subprocess.run(
318
339
  ["git", "-C", str(project), *args],
@@ -741,22 +762,65 @@ def make_entry(
741
762
  "source commit missing from local git history: " + final_commit[:12]
742
763
  )
743
764
  elif source_files:
744
- diff = run_git(project, ["diff", "--name-only", f"{final_commit}..HEAD", "--", *source_files])
745
- if diff.returncode == 0:
746
- changed = [line.strip() for line in diff.stdout.splitlines() if line.strip()]
765
+ diff_key = (
766
+ str(project.resolve()),
767
+ final_commit,
768
+ tuple(sorted(source_files)),
769
+ )
770
+ changed = _GIT_DIFF_CACHE.get(diff_key)
771
+ if changed is None and diff_key not in _GIT_DIFF_CACHE:
772
+ diff = run_git(
773
+ project,
774
+ [
775
+ "diff",
776
+ "--name-only",
777
+ f"{final_commit}..HEAD",
778
+ "--",
779
+ *sorted(source_files),
780
+ ],
781
+ )
782
+ changed = (
783
+ [
784
+ line.strip()
785
+ for line in diff.stdout.splitlines()
786
+ if line.strip()
787
+ ]
788
+ if diff.returncode == 0
789
+ else None
790
+ )
791
+ _GIT_DIFF_CACHE[diff_key] = changed
792
+ if changed is not None:
747
793
  if changed:
748
794
  stale_reasons.append(
749
795
  "source files changed after source commit: " + ", ".join(changed[:8])
750
796
  )
751
797
  else:
752
- detail = first_sentence(diff.stderr.strip() or f"git diff exited {diff.returncode}")
753
- stale_reasons.append("source commit could not be compared with current HEAD: " + detail)
798
+ stale_reasons.append(
799
+ "source commit could not be compared with current HEAD"
800
+ )
754
801
 
755
802
  if stale_reasons:
756
803
  status = "stale"
757
804
 
758
- identity = "|".join([project_name, archive, entry_type, title, body[:160]])
759
- entry_id = ".".join([project_name, slugify(archive, 80), entry_type, short_hash(identity)])
805
+ canonical_body = body.strip()
806
+ identity = json.dumps(
807
+ {
808
+ "schemaVersion": ENTRY_ID_SCHEMA_VERSION,
809
+ "projectId": project_name,
810
+ "sourceIdentity": {
811
+ "changeName": str(summary.get("changeName") or archive),
812
+ },
813
+ "type": entry_type,
814
+ "title": title.strip(),
815
+ "body": canonical_body,
816
+ },
817
+ ensure_ascii=False,
818
+ sort_keys=True,
819
+ separators=(",", ":"),
820
+ )
821
+ entry_id = ".".join(
822
+ [project_name, slugify(archive, 80), entry_type, short_hash(identity)]
823
+ )
760
824
  archive_dir = archive_dir_from_summary(summary_path)
761
825
 
762
826
  return {
@@ -767,7 +831,7 @@ def make_entry(
767
831
  "status": status,
768
832
  "title": title.strip(),
769
833
  "summary": first_sentence(body),
770
- "body": body.strip(),
834
+ "body": canonical_body,
771
835
  "keywords": sorted({kw for kw in keywords if kw}),
772
836
  "source": {
773
837
  "archive": rel_to_project(project, archive_dir),
@@ -1065,9 +1129,11 @@ def load_entries_from_dir(path: Path) -> list[dict[str, Any]]:
1065
1129
 
1066
1130
 
1067
1131
  def archive_entry_cache_path(project: Path, knowledge: Path, summary_path: Path, summary_hash: str) -> Path:
1068
- rel_summary = rel_to_project(project, summary_path)
1069
- cache_id = short_hash(rel_summary + "|" + summary_hash, 20)
1070
- filename = f"{safe_filename(archive_name(summary_path))}-{cache_id}.json"
1132
+ del project, summary_path
1133
+ cache_id = short_hash(
1134
+ f"extractor:{EXTRACTOR_SCHEMA_VERSION}|summary:{summary_hash}", 24
1135
+ )
1136
+ filename = f"content-{cache_id}.json"
1071
1137
  return knowledge / "cache" / "archive-entries" / filename
1072
1138
 
1073
1139
 
@@ -1085,9 +1151,7 @@ def load_cached_archive_entries(
1085
1151
  return None
1086
1152
  if not isinstance(payload, dict):
1087
1153
  return None
1088
- if payload.get("schemaVersion") != 1:
1089
- return None
1090
- if payload.get("summaryData") != summary_path:
1154
+ if payload.get("schemaVersion") != ARCHIVE_CACHE_SCHEMA_VERSION:
1091
1155
  return None
1092
1156
  if payload.get("summarySha256") != summary_hash:
1093
1157
  return None
@@ -1112,7 +1176,8 @@ def write_cached_archive_entries(
1112
1176
  write_json(
1113
1177
  cache_path,
1114
1178
  {
1115
- "schemaVersion": 1,
1179
+ "schemaVersion": ARCHIVE_CACHE_SCHEMA_VERSION,
1180
+ "extractorSchemaVersion": EXTRACTOR_SCHEMA_VERSION,
1116
1181
  "generatedAt": now_iso(),
1117
1182
  "summaryData": summary_path,
1118
1183
  "summarySha256": summary_hash,
@@ -1121,6 +1186,43 @@ def write_cached_archive_entries(
1121
1186
  )
1122
1187
 
1123
1188
 
1189
+ def rebind_cached_archive_entries(
1190
+ project: Path,
1191
+ summary_path: Path,
1192
+ summary_hash: str,
1193
+ entries: list[dict[str, Any]],
1194
+ ) -> list[dict[str, Any]]:
1195
+ rebound = json_clone(entries)
1196
+ summary_rel = rel_to_project(project, summary_path)
1197
+ archive_rel = rel_to_project(project, archive_dir_from_summary(summary_path))
1198
+ for entry in rebound:
1199
+ source = entry.setdefault("source", {})
1200
+ source["archive"] = archive_rel
1201
+ source["summaryData"] = summary_rel
1202
+ source["summarySha256"] = summary_hash
1203
+ return rebound
1204
+
1205
+
1206
+ def assert_unique_entry_ids(entries: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
1207
+ unique: dict[str, dict[str, Any]] = {}
1208
+ fingerprints: dict[str, str] = {}
1209
+ for entry in entries:
1210
+ entry_id = str(entry.get("id") or "")
1211
+ if not entry_id:
1212
+ raise ValueError("ENTRY_ID_MISSING: knowledge entry has no id")
1213
+ fingerprint = json.dumps(
1214
+ entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")
1215
+ )
1216
+ previous = fingerprints.get(entry_id)
1217
+ if previous is not None and previous != fingerprint:
1218
+ raise ValueError(
1219
+ f"ENTRY_ID_COLLISION: id {entry_id} maps to different payloads"
1220
+ )
1221
+ fingerprints[entry_id] = fingerprint
1222
+ unique[entry_id] = entry
1223
+ return unique
1224
+
1225
+
1124
1226
  def load_preserved_entries(knowledge: Path) -> list[dict[str, Any]]:
1125
1227
  active_entries = load_entries_from_dir(knowledge / "entries" / "active")
1126
1228
  manually_decided_entries = [
@@ -2299,6 +2401,45 @@ class KnowledgeSnapshot:
2299
2401
  self.inputs_hash = inputs_hash
2300
2402
 
2301
2403
 
2404
+ class ProgressReporter:
2405
+ def __init__(self, mode: str = "none") -> None:
2406
+ self.mode = mode
2407
+ self.started = time.monotonic()
2408
+ self.last_emit = 0.0
2409
+
2410
+ def emit(
2411
+ self,
2412
+ phase: str,
2413
+ completed: int,
2414
+ total: int,
2415
+ *,
2416
+ force: bool = False,
2417
+ metrics: dict[str, Any] | None = None,
2418
+ ) -> None:
2419
+ if self.mode == "none":
2420
+ return
2421
+ now = time.monotonic()
2422
+ if not force and now - self.last_emit < 10.0:
2423
+ return
2424
+ payload = {
2425
+ "phase": phase,
2426
+ "completed": completed,
2427
+ "total": total,
2428
+ "elapsedSeconds": round(now - self.started, 3),
2429
+ "heartbeat": not force,
2430
+ **(metrics or {}),
2431
+ }
2432
+ if self.mode == "jsonl":
2433
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
2434
+ else:
2435
+ sys.stderr.write(
2436
+ f"[knowledge] {phase} {completed}/{total} "
2437
+ f"elapsed={payload['elapsedSeconds']}s\n"
2438
+ )
2439
+ sys.stderr.flush()
2440
+ self.last_emit = now
2441
+
2442
+
2302
2443
  def build_snapshot(project: Path) -> KnowledgeSnapshot:
2303
2444
  """Load config + archive records + inputs_hash exactly once for one invocation."""
2304
2445
  project = project.resolve()
@@ -2318,7 +2459,11 @@ def build_index(
2318
2459
  incremental: bool = True,
2319
2460
  *,
2320
2461
  snapshot: KnowledgeSnapshot | None = None,
2462
+ progress: ProgressReporter | None = None,
2321
2463
  ) -> dict[str, Any]:
2464
+ global _GIT_SUBPROCESS_COUNT
2465
+ _GIT_SUBPROCESS_COUNT = 0
2466
+ reporter = progress or ProgressReporter("none")
2322
2467
  project = project.resolve()
2323
2468
  if snapshot is not None:
2324
2469
  # Reuse the single-invocation snapshot: no recomputation of
@@ -2337,6 +2482,7 @@ def build_index(
2337
2482
  archive_records = archive_summary_records(project, summary_paths)
2338
2483
  inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
2339
2484
  ensure_knowledge_dirs(knowledge)
2485
+ reporter.emit("snapshot", len(summary_paths), len(summary_paths), force=True)
2340
2486
 
2341
2487
  # No-op fast path: inputs (archive checksums + config + schema) are unchanged.
2342
2488
  # Write nothing — entries, sqlite, index and views all stay byte-identical,
@@ -2401,7 +2547,7 @@ def build_index(
2401
2547
  "sqliteDelete": 0,
2402
2548
  }
2403
2549
 
2404
- for summary_path in summary_paths:
2550
+ for summary_index, summary_path in enumerate(summary_paths, start=1):
2405
2551
  summary_rel = rel_to_project(project, summary_path)
2406
2552
  summary_hash = sha256_file(summary_path)
2407
2553
  cache_path = archive_entry_cache_path(project, knowledge, summary_path, summary_hash)
@@ -2426,6 +2572,9 @@ def build_index(
2426
2572
  ingest_mode["cacheWrites"] += 1
2427
2573
  else:
2428
2574
  ingest_mode["archivesReused"] += 1
2575
+ archive_entries = rebind_cached_archive_entries(
2576
+ project, summary_path, summary_hash, archive_entries
2577
+ )
2429
2578
  # API-006/RET-40: entries from archives failing the publication
2430
2579
  # gate stay quarantined (candidate only, never active/promoted).
2431
2580
  archive_rel = rel_to_project(project, archive_dir_from_summary(summary_path))
@@ -2441,6 +2590,7 @@ def build_index(
2441
2590
  entry["status"] = "candidate"
2442
2591
  ingest_mode["activeAutoDemoted"] += 1
2443
2592
  entries.extend(archive_entries)
2593
+ reporter.emit("archive-extract", summary_index, len(summary_paths))
2444
2594
  except Exception as exc: # keep one bad archive from blocking the index
2445
2595
  failures.append({"path": rel_to_project(project, summary_path), "error": str(exc)})
2446
2596
 
@@ -2468,8 +2618,11 @@ def build_index(
2468
2618
  seen.add(fp)
2469
2619
  deduped.append(entry)
2470
2620
 
2471
- near_dedupe = dedupe_near_duplicates(deduped)
2621
+ reporter.emit("near-deduplicate", 0, len(deduped), force=True)
2622
+ near_dedupe = dedupe_near_duplicates(deduped, progress=reporter)
2472
2623
  ingest_mode["nearDuplicatesMerged"] = near_dedupe["merged"]
2624
+ ingest_mode["similarityCandidates"] = near_dedupe["similarityCandidates"]
2625
+ ingest_mode["exactSimilarityComparisons"] = near_dedupe["exactSimilarityComparisons"]
2473
2626
 
2474
2627
  mark_conflicting_generated_entries(deduped)
2475
2628
  mark_degraded_test_evidence(deduped)
@@ -2479,8 +2632,10 @@ def build_index(
2479
2632
  auto_promotions = apply_auto_promote_policy(deduped, config)
2480
2633
  ingest_mode["confidenceScored"] = len(deduped)
2481
2634
  ingest_mode["candidateAutoPromoted"] = len(auto_promotions)
2635
+ assert_unique_entry_ids(deduped)
2482
2636
 
2483
2637
  preserved_ids = {entry["id"] for entry in load_preserved_entries(knowledge)}
2638
+ failed_entry_ids: set[str] = set()
2484
2639
  for entry in deduped:
2485
2640
  if entry["id"] in preserved_ids:
2486
2641
  continue
@@ -2495,12 +2650,20 @@ def build_index(
2495
2650
  if isinstance(existing, dict) and existing.get("id") != entry["id"]:
2496
2651
  failures.append({"id": entry["id"], "reason": "filename collision",
2497
2652
  "path": str(target), "conflictsWith": existing.get("id")})
2653
+ failed_entry_ids.add(entry["id"])
2498
2654
  continue
2499
2655
  _preserve_confidence_timestamp(target, entry)
2500
2656
  if write_json_if_changed(target, entry):
2501
2657
  ingest_mode["entriesWritten"] += 1
2502
2658
 
2503
- indexed_entries = combine_generated_with_preserved(knowledge, deduped)
2659
+ persisted_generated = [
2660
+ entry for entry in deduped if entry["id"] not in failed_entry_ids
2661
+ ]
2662
+ indexed_entries = combine_generated_with_preserved(
2663
+ knowledge, persisted_generated
2664
+ )
2665
+ indexed_by_id = assert_unique_entry_ids(indexed_entries)
2666
+ indexed_entries = list(indexed_by_id.values())
2504
2667
  apply_confidence_scores(indexed_entries, config)
2505
2668
  ingest_mode["entriesWritten"] += persist_entry_updates(knowledge, indexed_entries)
2506
2669
  auto_demotions = apply_active_lifecycle_policy(knowledge, indexed_entries, config)
@@ -2510,13 +2673,18 @@ def build_index(
2510
2673
  ingest_mode["validationFailed"] = validation["failed"]
2511
2674
  ingest_mode["validationAutoDemoted"] = validation["autoDemoted"]
2512
2675
  if auto_demotions:
2513
- indexed_entries = combine_generated_with_preserved(knowledge, deduped)
2676
+ indexed_entries = combine_generated_with_preserved(
2677
+ knowledge, persisted_generated
2678
+ )
2514
2679
  if validation["checked"]:
2515
2680
  indexed_entries = [entry for _, entry in load_entry_files(knowledge)]
2516
2681
  apply_confidence_scores(indexed_entries, config)
2517
2682
  ingest_mode["entriesWritten"] += persist_entry_updates(knowledge, indexed_entries)
2518
2683
  ingest_mode["confidenceScored"] = len(indexed_entries)
2519
2684
  ingest_mode["entriesPruned"] = prune_generated_entries(knowledge, indexed_entries)
2685
+ ingest_mode["uniqueEntryIdsWritten"] = len(indexed_entries)
2686
+ ingest_mode["stageWriteOperations"] = ingest_mode["entriesWritten"]
2687
+ ingest_mode["gitSubprocessCount"] = _GIT_SUBPROCESS_COUNT
2520
2688
 
2521
2689
  # Lifecycle policies above may relocate or mutate preserved entries. The
2522
2690
  # pre-build hash remains useful for the no-op gate, but the persisted index
@@ -2528,8 +2696,12 @@ def build_index(
2528
2696
  load_preserved_entries(knowledge),
2529
2697
  )
2530
2698
 
2699
+ reporter.emit("sqlite", 0, len(indexed_entries), force=True)
2531
2700
  sqlite_stats = write_sqlite(knowledge / "index.sqlite", indexed_entries)
2532
2701
  ingest_mode.update(sqlite_stats)
2702
+ ingest_mode["sqliteRowsTouched"] = (
2703
+ sqlite_stats.get("sqliteUpsert", 0) + sqlite_stats.get("sqliteDelete", 0)
2704
+ )
2533
2705
  index = make_manifest(
2534
2706
  project,
2535
2707
  pname,
@@ -2543,6 +2715,8 @@ def build_index(
2543
2715
  write_json_if_changed(knowledge / "index.json", index)
2544
2716
  write_views(knowledge, index, indexed_entries)
2545
2717
  write_ingest_report(knowledge, index, failures, duplicates)
2718
+ assert_persistence_invariants(knowledge, index, indexed_entries)
2719
+ reporter.emit("complete", len(indexed_entries), len(indexed_entries), force=True)
2546
2720
  return index
2547
2721
 
2548
2722
 
@@ -2797,6 +2971,7 @@ def make_manifest(
2797
2971
  duplicates: int,
2798
2972
  ingest_mode: dict[str, Any] | None = None,
2799
2973
  ) -> dict[str, Any]:
2974
+ entries = list(assert_unique_entry_ids(entries).values())
2800
2975
  stats = {status: 0 for status in ["candidate", "active", "stale", "superseded", "deprecated", "conflicted"]}
2801
2976
  by_type = {entry_type: 0 for entry_type in sorted(ENTRY_TYPES)}
2802
2977
  for entry in entries:
@@ -2859,6 +3034,37 @@ def make_manifest(
2859
3034
  }
2860
3035
 
2861
3036
 
3037
+ def assert_persistence_invariants(
3038
+ knowledge: Path,
3039
+ index: dict[str, Any],
3040
+ entries: list[dict[str, Any]],
3041
+ ) -> None:
3042
+ expected = set(assert_unique_entry_ids(entries))
3043
+ manifest_ids = [str(item.get("id") or "") for item in index.get("entries", [])]
3044
+ if len(manifest_ids) != len(set(manifest_ids)):
3045
+ raise ValueError("ENTRY_ID_COLLISION: duplicate IDs in knowledge manifest")
3046
+ file_entries = [
3047
+ entry
3048
+ for _, entry in load_entry_files(knowledge)
3049
+ if isinstance(entry, dict) and entry.get("id")
3050
+ ]
3051
+ file_ids = [str(entry["id"]) for entry in file_entries]
3052
+ if len(file_ids) != len(set(file_ids)):
3053
+ raise ValueError("ENTRY_ID_COLLISION: duplicate IDs across entry files")
3054
+ connection = sqlite3.connect(knowledge / "index.sqlite")
3055
+ try:
3056
+ sqlite_ids = {
3057
+ str(row[0])
3058
+ for row in connection.execute("select id from entries").fetchall()
3059
+ }
3060
+ finally:
3061
+ connection.close()
3062
+ if set(manifest_ids) != expected or set(file_ids) != expected or sqlite_ids != expected:
3063
+ raise ValueError(
3064
+ "KNOWLEDGE_PERSISTENCE_INVARIANT: manifest, files and SQLite IDs differ"
3065
+ )
3066
+
3067
+
2862
3068
  def write_sqlite(path: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
2863
3069
  """Persist entries to SQLite using a transactional dirty-set.
2864
3070
 
@@ -3627,13 +3833,19 @@ def summarize_index(index: dict[str, Any]) -> dict[str, Any]:
3627
3833
  }
3628
3834
 
3629
3835
 
3630
- def sync_status(project: Path, update: bool = False, incremental: bool = True) -> dict[str, Any]:
3836
+ def sync_status(
3837
+ project: Path,
3838
+ update: bool = False,
3839
+ incremental: bool = True,
3840
+ *,
3841
+ progress: ProgressReporter | None = None,
3842
+ ) -> dict[str, Any]:
3631
3843
  project = project.resolve()
3632
- knowledge = project / ".harness" / "knowledge"
3844
+ snapshot = build_snapshot(project)
3845
+ knowledge = snapshot.knowledge
3633
3846
  index_path = knowledge / "index.json"
3634
3847
  sqlite_path = knowledge / "index.sqlite"
3635
- summary_paths = discover_archive_summary_paths(project)
3636
- current_records = archive_summary_records(project, summary_paths)
3848
+ current_records = snapshot.archive_records
3637
3849
  reasons: list[str] = []
3638
3850
  index: dict[str, Any] | None = None
3639
3851
 
@@ -3670,14 +3882,19 @@ def sync_status(project: Path, update: bool = False, incremental: bool = True) -
3670
3882
  # rebuild. The archive checksum checks above already cover archive
3671
3883
  # changes; compare the full input fingerprint to catch config/schema
3672
3884
  # drift that the per-archive checks would miss.
3673
- current_inputs_hash = compute_inputs_hash(current_records, load_config(knowledge), knowledge)
3885
+ current_inputs_hash = snapshot.inputs_hash
3674
3886
  if current_inputs_hash != index.get("inputsHash") and not reasons:
3675
3887
  reasons.append("knowledge inputs changed (config or schema)")
3676
3888
 
3677
3889
  action = "none"
3678
3890
  refreshed: dict[str, Any] | None = None
3679
3891
  if reasons and update:
3680
- refreshed = build_index(project, incremental=incremental)
3892
+ refreshed = build_index(
3893
+ project,
3894
+ incremental=incremental,
3895
+ snapshot=snapshot,
3896
+ progress=progress,
3897
+ )
3681
3898
  action = "ingested"
3682
3899
  reasons = []
3683
3900
 
@@ -4080,6 +4297,8 @@ def supersede_entry(entry: dict[str, Any], newer_id: str, reason: str) -> None:
4080
4297
  def dedupe_near_duplicates(
4081
4298
  entries: list[dict[str, Any]],
4082
4299
  threshold: float = NEAR_DUPLICATE_THRESHOLD,
4300
+ *,
4301
+ progress: ProgressReporter | None = None,
4083
4302
  ) -> dict[str, Any]:
4084
4303
  """Merge near-duplicate entries within the same archive (in-place)."""
4085
4304
  by_archive: dict[str, list[dict[str, Any]]] = {}
@@ -4090,6 +4309,47 @@ def dedupe_near_duplicates(
4090
4309
  by_archive.setdefault(archive, []).append(entry)
4091
4310
 
4092
4311
  merges: list[dict[str, Any]] = []
4312
+ normalized = {
4313
+ id(entry): entry_compare_text(entry)
4314
+ for entry in entries
4315
+ }
4316
+ similarity_cache: dict[tuple[int, int], float] = {}
4317
+ similarity_candidates = 0
4318
+ exact_comparisons = 0
4319
+
4320
+ def similarity(left: dict[str, Any], right: dict[str, Any]) -> float:
4321
+ nonlocal similarity_candidates, exact_comparisons
4322
+ pair = tuple(sorted((id(left), id(right))))
4323
+ if pair in similarity_cache:
4324
+ return similarity_cache[pair]
4325
+ left_text = normalized[id(left)]
4326
+ right_text = normalized[id(right)]
4327
+ if not left_text or not right_text:
4328
+ similarity_cache[pair] = 0.0
4329
+ return 0.0
4330
+ shorter = min(len(left_text), len(right_text))
4331
+ longer = max(len(left_text), len(right_text))
4332
+ # SequenceMatcher's ratio cannot exceed this length-only upper bound.
4333
+ if longer == 0 or (2 * shorter / (shorter + longer)) < threshold:
4334
+ similarity_cache[pair] = 0.0
4335
+ return 0.0
4336
+ similarity_candidates += 1
4337
+ matcher = SequenceMatcher(None, left_text, right_text)
4338
+ if matcher.real_quick_ratio() < threshold or matcher.quick_ratio() < threshold:
4339
+ similarity_cache[pair] = 0.0
4340
+ return 0.0
4341
+ exact_comparisons += 1
4342
+ value = matcher.ratio()
4343
+ similarity_cache[pair] = value
4344
+ if progress is not None:
4345
+ progress.emit(
4346
+ "near-deduplicate",
4347
+ similarity_candidates,
4348
+ len(entries),
4349
+ metrics={"exactSimilarityComparisons": exact_comparisons},
4350
+ )
4351
+ return value
4352
+
4093
4353
  for archive, group in by_archive.items():
4094
4354
  remaining = list(group)
4095
4355
  while remaining:
@@ -4104,7 +4364,7 @@ def dedupe_near_duplicates(
4104
4364
  if other.get("type") != current.get("type"):
4105
4365
  still.append(other)
4106
4366
  continue
4107
- if entry_similarity(current, other) >= threshold:
4367
+ if similarity(current, other) >= threshold:
4108
4368
  cluster.append(other)
4109
4369
  else:
4110
4370
  still.append(other)
@@ -4128,10 +4388,15 @@ def dedupe_near_duplicates(
4128
4388
  "keptId": keeper["id"],
4129
4389
  "mergedId": absorbed["id"],
4130
4390
  "archive": archive,
4131
- "similarity": round(entry_similarity(keeper, absorbed), 3),
4391
+ "similarity": round(similarity(keeper, absorbed), 3),
4132
4392
  }
4133
4393
  )
4134
- return {"merged": len(merges), "merges": merges}
4394
+ return {
4395
+ "merged": len(merges),
4396
+ "merges": merges,
4397
+ "similarityCandidates": similarity_candidates,
4398
+ "exactSimilarityComparisons": exact_comparisons,
4399
+ }
4135
4400
 
4136
4401
 
4137
4402
  def relocate_entry_file(
@@ -4922,6 +5187,12 @@ def main(argv: list[str] | None = None) -> int:
4922
5187
  action="store_true",
4923
5188
  help="Re-extract every archive instead of reusing the archive entry cache",
4924
5189
  )
5190
+ ingest.add_argument(
5191
+ "--progress",
5192
+ choices=["jsonl", "text", "none"],
5193
+ default="none",
5194
+ help="Write phase progress to stderr; stdout remains final JSON",
5195
+ )
4925
5196
 
4926
5197
  sync = sub.add_parser("sync", help="Check whether .harness/knowledge is current")
4927
5198
  sync.add_argument("--project", default=".", help="Project root containing .harness/archive")
@@ -4931,6 +5202,17 @@ def main(argv: list[str] | None = None) -> int:
4931
5202
  action="store_true",
4932
5203
  help="When used with --update, rebuild without reusing the archive entry cache",
4933
5204
  )
5205
+ sync.add_argument(
5206
+ "--json",
5207
+ action="store_true",
5208
+ help="Emit machine-readable JSON (default behavior)",
5209
+ )
5210
+ sync.add_argument(
5211
+ "--progress",
5212
+ choices=["jsonl", "text", "none"],
5213
+ default="none",
5214
+ help="Write phase progress to stderr; stdout remains final JSON",
5215
+ )
4934
5216
 
4935
5217
  auto = sub.add_parser("auto", help="Run the default automated knowledge maintenance workflow")
4936
5218
  auto.add_argument("--project", default=".", help="Project root containing .harness/archive")
@@ -5041,11 +5323,20 @@ def main(argv: list[str] | None = None) -> int:
5041
5323
 
5042
5324
  args = parser.parse_args(argv)
5043
5325
  if args.command == "ingest":
5044
- index = build_index(Path(args.project), incremental=not args.no_incremental)
5326
+ index = build_index(
5327
+ Path(args.project),
5328
+ incremental=not args.no_incremental,
5329
+ progress=ProgressReporter(args.progress),
5330
+ )
5045
5331
  print(json.dumps(summarize_index(index), ensure_ascii=False, indent=2))
5046
5332
  return 0
5047
5333
  if args.command == "sync":
5048
- result = sync_status(Path(args.project), args.update, incremental=not args.no_incremental)
5334
+ result = sync_status(
5335
+ Path(args.project),
5336
+ args.update,
5337
+ incremental=not args.no_incremental,
5338
+ progress=ProgressReporter(args.progress),
5339
+ )
5049
5340
  print(json.dumps(result, ensure_ascii=False, indent=2))
5050
5341
  return 0
5051
5342
  if args.command == "auto":
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=9a2ab44ad7a34065; overlay=none; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=370a50726f655b0c; overlay=none; agent=claude-code; do not edit -->
24
24
  # harness-knowledge-query
25
25
 
26
26
  并行 change 中查询必须传 `--change <change-id>`;成功后以该 change 的 `meta/knowledge-context.json` 为稳定指针。全局 `context-packs/latest.json` 仅向后兼容,不得用于判断多个 active change 的上下文归属。
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=9a2ab44ad7a34065; overlay=none; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=370a50726f655b0c; overlay=none; agent=claude-code; do not edit -->
24
24
  # harness-plan — 需求规划
25
25
 
26
26
  ## Purpose