@hunter-harness/workflow-harness 0.2.41 → 0.2.43
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +6 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/claude-code/harness-sync/reference.md +34 -14
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +34 -14
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/reference.md +1 -0
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/reference.md +6 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/codex/harness-sync/reference.md +34 -14
- package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/reference.md +1 -0
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +6 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/cursor/harness-sync/reference.md +34 -14
- package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/cursor/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +16 -10
- package/harness/bundles/java/claude-code/harness-sync/reference.md +34 -14
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +16 -10
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +34 -14
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/reference.md +1 -0
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +16 -10
- package/harness/bundles/java/codex/harness-sync/reference.md +34 -14
- package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/reference.md +1 -0
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +16 -10
- package/harness/bundles/java/cursor/harness-sync/reference.md +34 -14
- package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/cursor/scripts/harness_verification.py +408 -8
- package/harness/manifests/general/claude-code.json +46 -33
- package/harness/manifests/general/codebuddy.json +46 -33
- package/harness/manifests/general/codex.json +46 -33
- package/harness/manifests/general/cursor.json +46 -33
- package/harness/manifests/java/claude-code.json +47 -34
- package/harness/manifests/java/codebuddy.json +47 -34
- package/harness/manifests/java/codex.json +47 -34
- package/harness/manifests/java/cursor.json +47 -34
- package/hunter-workflow-family.json +12 -10
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import datetime as dt
|
|
14
|
+
import gzip
|
|
14
15
|
import hashlib
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
@@ -20,6 +21,7 @@ import sqlite3
|
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
22
23
|
import time
|
|
24
|
+
import unicodedata
|
|
23
25
|
from difflib import SequenceMatcher
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
from typing import Any
|
|
@@ -42,8 +44,8 @@ ENTRY_TYPES = {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
ENTRY_ID_SCHEMA_VERSION = 2
|
|
45
|
-
ARCHIVE_CACHE_SCHEMA_VERSION =
|
|
46
|
-
EXTRACTOR_SCHEMA_VERSION =
|
|
47
|
+
ARCHIVE_CACHE_SCHEMA_VERSION = 3
|
|
48
|
+
EXTRACTOR_SCHEMA_VERSION = 3
|
|
47
49
|
_GIT_REPO_CACHE: dict[str, bool] = {}
|
|
48
50
|
_GIT_COMMIT_CACHE: dict[tuple[str, str], bool] = {}
|
|
49
51
|
_GIT_DIFF_CACHE: dict[tuple[str, str, tuple[str, ...]], list[str] | None] = {}
|
|
@@ -133,6 +135,92 @@ def json_clone(value: Any) -> Any:
|
|
|
133
135
|
return json.loads(json.dumps(value, ensure_ascii=False))
|
|
134
136
|
|
|
135
137
|
|
|
138
|
+
def normalize_knowledge_text(value: Any) -> str:
|
|
139
|
+
"""Canonical text used for identity-independent duplicate detection."""
|
|
140
|
+
text = unicodedata.normalize("NFKC", str(value or "")).strip()
|
|
141
|
+
if not text:
|
|
142
|
+
return ""
|
|
143
|
+
if text[:1] in {"{", "["}:
|
|
144
|
+
try:
|
|
145
|
+
parsed = json.loads(text)
|
|
146
|
+
except (json.JSONDecodeError, TypeError):
|
|
147
|
+
pass
|
|
148
|
+
else:
|
|
149
|
+
return json.dumps(
|
|
150
|
+
parsed,
|
|
151
|
+
ensure_ascii=False,
|
|
152
|
+
sort_keys=True,
|
|
153
|
+
separators=(",", ":"),
|
|
154
|
+
).casefold()
|
|
155
|
+
text = text.translate(str.maketrans({
|
|
156
|
+
",": ",",
|
|
157
|
+
"。": ".",
|
|
158
|
+
";": ";",
|
|
159
|
+
":": ":",
|
|
160
|
+
"!": "!",
|
|
161
|
+
"?": "?",
|
|
162
|
+
"“": '"',
|
|
163
|
+
"”": '"',
|
|
164
|
+
"‘": "'",
|
|
165
|
+
"’": "'",
|
|
166
|
+
}))
|
|
167
|
+
text = " ".join(text.split()).casefold()
|
|
168
|
+
return re.sub(r"\s*([,.;:!?])\s*", r"\1", text)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def sha256_text(value: str) -> str:
|
|
172
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _report_hash_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
stable = json_clone(payload)
|
|
177
|
+
stable.pop("generatedAt", None)
|
|
178
|
+
stable.pop("exportPath", None)
|
|
179
|
+
stable.pop("judgement", None)
|
|
180
|
+
return stable
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def update_latest_report_pointer(
|
|
184
|
+
knowledge: Path,
|
|
185
|
+
kind: str,
|
|
186
|
+
report_path: Path,
|
|
187
|
+
digest: str,
|
|
188
|
+
) -> None:
|
|
189
|
+
latest_path = knowledge / "reports" / "latest.json"
|
|
190
|
+
latest: dict[str, Any] = {"schemaVersion": 1, "reports": {}}
|
|
191
|
+
if latest_path.exists():
|
|
192
|
+
try:
|
|
193
|
+
current = read_json(latest_path)
|
|
194
|
+
if isinstance(current, dict) and isinstance(current.get("reports"), dict):
|
|
195
|
+
latest = current
|
|
196
|
+
except (OSError, json.JSONDecodeError):
|
|
197
|
+
pass
|
|
198
|
+
reports = dict(latest.get("reports") or {})
|
|
199
|
+
reports[kind] = {
|
|
200
|
+
"path": str(report_path.relative_to(knowledge)).replace("\\", "/"),
|
|
201
|
+
"sha256": digest,
|
|
202
|
+
}
|
|
203
|
+
write_json_if_changed(
|
|
204
|
+
latest_path,
|
|
205
|
+
{"schemaVersion": 1, "reports": dict(sorted(reports.items()))},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def write_content_addressed_json_report(
|
|
210
|
+
knowledge: Path,
|
|
211
|
+
kind: str,
|
|
212
|
+
payload: dict[str, Any],
|
|
213
|
+
) -> tuple[Path, str]:
|
|
214
|
+
stable = _report_hash_payload(payload)
|
|
215
|
+
digest = sha256_text(
|
|
216
|
+
json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
217
|
+
)
|
|
218
|
+
path = knowledge / "reports" / f"{kind}-{digest}.json"
|
|
219
|
+
write_json_if_changed(path, payload)
|
|
220
|
+
update_latest_report_pointer(knowledge, kind, path, digest)
|
|
221
|
+
return path, digest
|
|
222
|
+
|
|
223
|
+
|
|
136
224
|
def load_config(knowledge: Path) -> dict[str, Any]:
|
|
137
225
|
config_path = knowledge / "config.json"
|
|
138
226
|
if not config_path.exists():
|
|
@@ -612,12 +700,48 @@ def discover_archive_summary_paths(project: Path) -> list[Path]:
|
|
|
612
700
|
return selected
|
|
613
701
|
|
|
614
702
|
|
|
703
|
+
def legacy_publication_attestation(
|
|
704
|
+
project: Path,
|
|
705
|
+
archive_rel: str,
|
|
706
|
+
summary_sha256: str,
|
|
707
|
+
) -> dict[str, Any] | None:
|
|
708
|
+
path = project / ".harness" / "knowledge" / "publication-attestations.json"
|
|
709
|
+
if not path.exists():
|
|
710
|
+
return None
|
|
711
|
+
try:
|
|
712
|
+
payload = read_json(path)
|
|
713
|
+
except (OSError, json.JSONDecodeError):
|
|
714
|
+
return None
|
|
715
|
+
records = payload.get("records") if isinstance(payload, dict) else None
|
|
716
|
+
record = records.get(archive_rel) if isinstance(records, dict) else None
|
|
717
|
+
if (
|
|
718
|
+
isinstance(record, dict)
|
|
719
|
+
and record.get("status") == "verified"
|
|
720
|
+
and record.get("summarySha256") == summary_sha256
|
|
721
|
+
):
|
|
722
|
+
return record
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
|
|
615
726
|
def _publication_status_from_resolution(
|
|
616
727
|
project: Path,
|
|
617
728
|
archive_dir: Path,
|
|
618
729
|
resolution: dict[str, Any],
|
|
619
730
|
) -> dict[str, Any]:
|
|
620
|
-
result: dict[str, Any] = {
|
|
731
|
+
result: dict[str, Any] = {
|
|
732
|
+
"status": "missing",
|
|
733
|
+
"allowed": False,
|
|
734
|
+
"reasons": [],
|
|
735
|
+
"archiveReleaseEligibility": {
|
|
736
|
+
"status": "UNKNOWN",
|
|
737
|
+
"eligible": False,
|
|
738
|
+
},
|
|
739
|
+
"knowledgePublicationEligibility": {
|
|
740
|
+
"status": "missing",
|
|
741
|
+
"allowed": False,
|
|
742
|
+
"reasons": [],
|
|
743
|
+
},
|
|
744
|
+
}
|
|
621
745
|
if resolution.get("version"):
|
|
622
746
|
result["authoritativeVersion"] = resolution["version"]
|
|
623
747
|
|
|
@@ -652,10 +776,58 @@ def _publication_status_from_resolution(
|
|
|
652
776
|
result["reasons"].append("summary-data.json must be an object")
|
|
653
777
|
return result
|
|
654
778
|
|
|
779
|
+
final_status = str(summary.get("finalStatus") or "").upper()
|
|
780
|
+
result["archiveReleaseEligibility"] = {
|
|
781
|
+
"status": final_status or "UNKNOWN",
|
|
782
|
+
"eligible": final_status in {"OK", "CONDITIONAL_OK"},
|
|
783
|
+
}
|
|
784
|
+
explicit_gate = summary.get("knowledgePublicationEligibility")
|
|
785
|
+
if isinstance(explicit_gate, dict):
|
|
786
|
+
explicit_allowed = explicit_gate.get("allowed") is True
|
|
787
|
+
explicit_reasons = [
|
|
788
|
+
str(reason) for reason in (explicit_gate.get("reasons") or [])
|
|
789
|
+
]
|
|
790
|
+
result["knowledgePublicationEligibility"] = {
|
|
791
|
+
"status": str(explicit_gate.get("status") or (
|
|
792
|
+
"ok" if explicit_allowed else "unverified"
|
|
793
|
+
)).lower(),
|
|
794
|
+
"allowed": explicit_allowed,
|
|
795
|
+
"reasons": explicit_reasons,
|
|
796
|
+
}
|
|
797
|
+
result["status"] = (
|
|
798
|
+
"ok" if explicit_allowed
|
|
799
|
+
else result["knowledgePublicationEligibility"]["status"]
|
|
800
|
+
)
|
|
801
|
+
result["allowed"] = explicit_allowed
|
|
802
|
+
result["reasons"] = explicit_reasons
|
|
803
|
+
return result
|
|
804
|
+
|
|
655
805
|
consistency = (summary.get("reportPipeline") or {}).get("sourceConsistency")
|
|
656
806
|
if consistency is None:
|
|
807
|
+
archive_rel = rel_to_project(project, archive_dir)
|
|
808
|
+
attestation = legacy_publication_attestation(
|
|
809
|
+
project,
|
|
810
|
+
archive_rel,
|
|
811
|
+
summary_hash,
|
|
812
|
+
)
|
|
813
|
+
if attestation is not None:
|
|
814
|
+
result["status"] = "ok"
|
|
815
|
+
result["allowed"] = True
|
|
816
|
+
result["knowledgePublicationEligibility"] = {
|
|
817
|
+
"status": "ok",
|
|
818
|
+
"allowed": True,
|
|
819
|
+
"reasons": [],
|
|
820
|
+
"evidence": "legacy-publication-attestation",
|
|
821
|
+
"attestationSha256": attestation.get("attestationSha256"),
|
|
822
|
+
}
|
|
823
|
+
return result
|
|
657
824
|
result["status"] = "unverified"
|
|
658
825
|
result["reasons"].append("source consistency never ran")
|
|
826
|
+
result["knowledgePublicationEligibility"] = {
|
|
827
|
+
"status": "unverified",
|
|
828
|
+
"allowed": False,
|
|
829
|
+
"reasons": list(result["reasons"]),
|
|
830
|
+
}
|
|
659
831
|
return result
|
|
660
832
|
if not consistency.get("ok"):
|
|
661
833
|
result["status"] = "failed"
|
|
@@ -667,18 +839,24 @@ def _publication_status_from_resolution(
|
|
|
667
839
|
result["reasons"].append(
|
|
668
840
|
"source consistency failed" + (f": {', '.join(codes)}" if codes else "")
|
|
669
841
|
)
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
result["reasons"].append(
|
|
676
|
-
f"authoritative finalStatus is not publishable: {final_status or 'missing'}"
|
|
677
|
-
)
|
|
842
|
+
result["knowledgePublicationEligibility"] = {
|
|
843
|
+
"status": "failed",
|
|
844
|
+
"allowed": False,
|
|
845
|
+
"reasons": list(result["reasons"]),
|
|
846
|
+
}
|
|
678
847
|
return result
|
|
679
848
|
|
|
680
849
|
result["status"] = "ok"
|
|
681
850
|
result["allowed"] = True
|
|
851
|
+
result["knowledgePublicationEligibility"] = {
|
|
852
|
+
"status": "ok",
|
|
853
|
+
"allowed": True,
|
|
854
|
+
"reasons": [],
|
|
855
|
+
}
|
|
856
|
+
if final_status not in {"OK", "CONDITIONAL_OK"}:
|
|
857
|
+
result["archiveReleaseEligibility"]["advisory"] = (
|
|
858
|
+
"Archive release status does not blanket-block source-consistent knowledge."
|
|
859
|
+
)
|
|
682
860
|
return result
|
|
683
861
|
|
|
684
862
|
|
|
@@ -1061,10 +1239,97 @@ def keyword_candidates(summary: dict[str, Any], extra: list[str] | None = None)
|
|
|
1061
1239
|
return tokens[:40]
|
|
1062
1240
|
|
|
1063
1241
|
|
|
1242
|
+
def looks_like_process_observation(text: str) -> bool:
|
|
1243
|
+
normalized = normalize_knowledge_text(text).lower()
|
|
1244
|
+
process_markers = (
|
|
1245
|
+
"worktree",
|
|
1246
|
+
"preflight",
|
|
1247
|
+
"reviewer path",
|
|
1248
|
+
"git diff",
|
|
1249
|
+
"git status",
|
|
1250
|
+
"commit status",
|
|
1251
|
+
"baseline dirty",
|
|
1252
|
+
"user baseline",
|
|
1253
|
+
"test slow threshold",
|
|
1254
|
+
".gitattributes",
|
|
1255
|
+
"harness execution",
|
|
1256
|
+
)
|
|
1257
|
+
return any(marker in normalized for marker in process_markers)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def extract_explicit_knowledge_candidates(
|
|
1261
|
+
project: Path,
|
|
1262
|
+
project_name: str,
|
|
1263
|
+
summary_path: Path,
|
|
1264
|
+
summary: dict[str, Any],
|
|
1265
|
+
summary_hash: str,
|
|
1266
|
+
fallback_files: list[str],
|
|
1267
|
+
) -> list[dict[str, Any]] | None:
|
|
1268
|
+
raw_candidates = summary.get("knowledgeCandidates")
|
|
1269
|
+
if not isinstance(raw_candidates, list):
|
|
1270
|
+
return None
|
|
1271
|
+
entries: list[dict[str, Any]] = []
|
|
1272
|
+
for item in raw_candidates:
|
|
1273
|
+
if not isinstance(item, dict):
|
|
1274
|
+
continue
|
|
1275
|
+
category = str(item.get("category") or "").strip().lower()
|
|
1276
|
+
title = str(item.get("title") or "").strip()
|
|
1277
|
+
body = str(item.get("body") or item.get("summary") or "").strip()
|
|
1278
|
+
if category == "process-observation" or looks_like_process_observation(
|
|
1279
|
+
f"{title} {body}"
|
|
1280
|
+
):
|
|
1281
|
+
continue
|
|
1282
|
+
entry_type = str(item.get("type") or "implementation")
|
|
1283
|
+
if entry_type not in ENTRY_TYPES or not title or not body:
|
|
1284
|
+
continue
|
|
1285
|
+
raw_files = item.get("sourceFiles")
|
|
1286
|
+
source_files = (
|
|
1287
|
+
[str(path) for path in raw_files if path]
|
|
1288
|
+
if isinstance(raw_files, list)
|
|
1289
|
+
else fallback_files
|
|
1290
|
+
)
|
|
1291
|
+
raw_keywords = item.get("keywords")
|
|
1292
|
+
keywords = keyword_candidates(
|
|
1293
|
+
summary,
|
|
1294
|
+
[str(value) for value in raw_keywords if value]
|
|
1295
|
+
if isinstance(raw_keywords, list)
|
|
1296
|
+
else [entry_type],
|
|
1297
|
+
)
|
|
1298
|
+
confidence = str(item.get("confidence") or "medium")
|
|
1299
|
+
if confidence not in {"low", "medium", "high"}:
|
|
1300
|
+
confidence = "medium"
|
|
1301
|
+
entries.append(
|
|
1302
|
+
make_entry(
|
|
1303
|
+
project=project,
|
|
1304
|
+
project_name=project_name,
|
|
1305
|
+
summary_path=summary_path,
|
|
1306
|
+
summary_hash=summary_hash,
|
|
1307
|
+
summary=summary,
|
|
1308
|
+
entry_type=entry_type,
|
|
1309
|
+
title=title,
|
|
1310
|
+
body=body,
|
|
1311
|
+
source_files=source_files,
|
|
1312
|
+
keywords=keywords,
|
|
1313
|
+
confidence=confidence,
|
|
1314
|
+
)
|
|
1315
|
+
)
|
|
1316
|
+
return entries
|
|
1317
|
+
|
|
1318
|
+
|
|
1064
1319
|
def extract_entries(project: Path, project_name: str, summary_path: Path) -> list[dict[str, Any]]:
|
|
1065
1320
|
summary = read_json(summary_path)
|
|
1066
1321
|
summary_hash = sha256_file(summary_path)
|
|
1067
1322
|
files = changed_file_paths(summary)
|
|
1323
|
+
explicit = extract_explicit_knowledge_candidates(
|
|
1324
|
+
project,
|
|
1325
|
+
project_name,
|
|
1326
|
+
summary_path,
|
|
1327
|
+
summary,
|
|
1328
|
+
summary_hash,
|
|
1329
|
+
files,
|
|
1330
|
+
)
|
|
1331
|
+
if explicit is not None:
|
|
1332
|
+
return explicit
|
|
1068
1333
|
entries: list[dict[str, Any]] = []
|
|
1069
1334
|
goal = str(summary.get("businessGoal") or "").strip()
|
|
1070
1335
|
|
|
@@ -1087,7 +1352,7 @@ def extract_entries(project: Path, project_name: str, summary_path: Path) -> lis
|
|
|
1087
1352
|
|
|
1088
1353
|
for idx, note in enumerate(summary.get("maintenanceNotes") or [], start=1):
|
|
1089
1354
|
text = str(note).strip()
|
|
1090
|
-
if not text:
|
|
1355
|
+
if not text or looks_like_process_observation(text):
|
|
1091
1356
|
continue
|
|
1092
1357
|
entry_type = "decision" if looks_like_decision(text) else "implementation"
|
|
1093
1358
|
entries.append(
|
|
@@ -1188,7 +1453,11 @@ def extract_entries(project: Path, project_name: str, summary_path: Path) -> lis
|
|
|
1188
1453
|
)
|
|
1189
1454
|
|
|
1190
1455
|
review = summary.get("reviewSummary")
|
|
1191
|
-
if
|
|
1456
|
+
if (
|
|
1457
|
+
isinstance(review, dict)
|
|
1458
|
+
and review.get("summary")
|
|
1459
|
+
and not looks_like_process_observation(str(review.get("summary")))
|
|
1460
|
+
):
|
|
1192
1461
|
body = str(review.get("summary"))
|
|
1193
1462
|
entries.append(
|
|
1194
1463
|
make_entry(
|
|
@@ -1831,6 +2100,39 @@ def subject_terms(entry: dict[str, Any]) -> set[str]:
|
|
|
1831
2100
|
return {term for term in terms if term not in stop}
|
|
1832
2101
|
|
|
1833
2102
|
|
|
2103
|
+
def entity_keys(entry: dict[str, Any]) -> set[str]:
|
|
2104
|
+
text = " ".join(
|
|
2105
|
+
str(value or "")
|
|
2106
|
+
for value in (entry.get("title"), entry.get("summary"), entry.get("body"))
|
|
2107
|
+
)
|
|
2108
|
+
keys = {
|
|
2109
|
+
match.strip()
|
|
2110
|
+
for match in re.findall(r"`([^`\n]{2,120})`", text)
|
|
2111
|
+
if match.strip()
|
|
2112
|
+
}
|
|
2113
|
+
keys.update(
|
|
2114
|
+
re.findall(
|
|
2115
|
+
r"\b[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+\b",
|
|
2116
|
+
text,
|
|
2117
|
+
)
|
|
2118
|
+
)
|
|
2119
|
+
keys.update(
|
|
2120
|
+
token
|
|
2121
|
+
for token in re.findall(r"\b[A-Za-z_][A-Za-z0-9_]{3,}\b", text)
|
|
2122
|
+
if "_" in token or any(character.isupper() for character in token[1:])
|
|
2123
|
+
)
|
|
2124
|
+
stop = {
|
|
2125
|
+
"Harness",
|
|
2126
|
+
"README",
|
|
2127
|
+
"SKILL",
|
|
2128
|
+
"changeName",
|
|
2129
|
+
"finalStatus",
|
|
2130
|
+
"sourceFiles",
|
|
2131
|
+
"summaryData",
|
|
2132
|
+
}
|
|
2133
|
+
return {key for key in keys if key not in stop}
|
|
2134
|
+
|
|
2135
|
+
|
|
1834
2136
|
def has_replacement_signal(text: str) -> bool:
|
|
1835
2137
|
needles = [
|
|
1836
2138
|
"不再",
|
|
@@ -1854,61 +2156,70 @@ def has_stability_signal(text: str) -> bool:
|
|
|
1854
2156
|
return any(needle in text for needle in needles)
|
|
1855
2157
|
|
|
1856
2158
|
|
|
1857
|
-
def
|
|
2159
|
+
def conflict_evidence(
|
|
2160
|
+
left: dict[str, Any],
|
|
2161
|
+
right: dict[str, Any],
|
|
2162
|
+
) -> dict[str, Any] | None:
|
|
1858
2163
|
comparable_types = {"requirement", "decision", "api-contract"}
|
|
1859
2164
|
if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
|
|
1860
|
-
return
|
|
1861
|
-
if
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2165
|
+
return None
|
|
2166
|
+
if (
|
|
2167
|
+
str(left.get("category") or "") == "process-observation"
|
|
2168
|
+
or str(right.get("category") or "") == "process-observation"
|
|
2169
|
+
or looks_like_process_observation(normalized_entry_text(left))
|
|
2170
|
+
or looks_like_process_observation(normalized_entry_text(right))
|
|
2171
|
+
):
|
|
2172
|
+
return None
|
|
1866
2173
|
left_files = set(left.get("scope", {}).get("sourceFiles") or [])
|
|
1867
2174
|
right_files = set(right.get("scope", {}).get("sourceFiles") or [])
|
|
1868
2175
|
if not left_files or not right_files or not (left_files & right_files):
|
|
1869
|
-
return
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
return False
|
|
1874
|
-
|
|
2176
|
+
return None
|
|
2177
|
+
shared_entities = sorted(entity_keys(left) & entity_keys(right))
|
|
2178
|
+
if not shared_entities:
|
|
2179
|
+
return None
|
|
1875
2180
|
left_text = normalized_entry_text(left)
|
|
1876
2181
|
right_text = normalized_entry_text(right)
|
|
1877
|
-
|
|
2182
|
+
mutually_exclusive = (
|
|
1878
2183
|
has_replacement_signal(left_text)
|
|
1879
2184
|
and has_stability_signal(right_text)
|
|
1880
2185
|
) or (
|
|
1881
2186
|
has_replacement_signal(right_text)
|
|
1882
2187
|
and has_stability_signal(left_text)
|
|
1883
2188
|
)
|
|
2189
|
+
if not mutually_exclusive:
|
|
2190
|
+
return None
|
|
2191
|
+
return {
|
|
2192
|
+
"sharedEntities": shared_entities,
|
|
2193
|
+
"sharedSourceFiles": sorted(left_files & right_files),
|
|
2194
|
+
"mutuallyExclusiveAssertions": True,
|
|
2195
|
+
"leftSignals": {
|
|
2196
|
+
"replacement": has_replacement_signal(left_text),
|
|
2197
|
+
"stability": has_stability_signal(left_text),
|
|
2198
|
+
},
|
|
2199
|
+
"rightSignals": {
|
|
2200
|
+
"replacement": has_replacement_signal(right_text),
|
|
2201
|
+
"stability": has_stability_signal(right_text),
|
|
2202
|
+
},
|
|
2203
|
+
}
|
|
1884
2204
|
|
|
1885
2205
|
|
|
1886
|
-
def
|
|
1887
|
-
|
|
1888
|
-
if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
|
|
2206
|
+
def entries_conflict(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
2207
|
+
if left.get("status") in {"active", "stale", "superseded", "conflicted"}:
|
|
1889
2208
|
return False
|
|
1890
|
-
|
|
1891
|
-
left_files = set(left.get("scope", {}).get("sourceFiles") or [])
|
|
1892
|
-
right_files = set(right.get("scope", {}).get("sourceFiles") or [])
|
|
1893
|
-
if not left_files or not right_files or not (left_files & right_files):
|
|
2209
|
+
if right.get("status") in {"active", "stale", "superseded", "conflicted"}:
|
|
1894
2210
|
return False
|
|
2211
|
+
return conflict_evidence(left, right) is not None
|
|
1895
2212
|
|
|
1896
|
-
shared_terms = subject_terms(left) & subject_terms(right)
|
|
1897
|
-
if not shared_terms:
|
|
1898
|
-
return False
|
|
1899
2213
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
return (
|
|
1903
|
-
has_replacement_signal(left_text)
|
|
1904
|
-
and has_stability_signal(right_text)
|
|
1905
|
-
) or (
|
|
1906
|
-
has_replacement_signal(right_text)
|
|
1907
|
-
and has_stability_signal(left_text)
|
|
1908
|
-
)
|
|
2214
|
+
def entries_conflict_for_review(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
2215
|
+
return conflict_evidence(left, right) is not None
|
|
1909
2216
|
|
|
1910
2217
|
|
|
1911
|
-
def mark_conflict(
|
|
2218
|
+
def mark_conflict(
|
|
2219
|
+
left: dict[str, Any],
|
|
2220
|
+
right: dict[str, Any],
|
|
2221
|
+
evidence: dict[str, Any] | None = None,
|
|
2222
|
+
) -> None:
|
|
1912
2223
|
for entry, other in [(left, right), (right, left)]:
|
|
1913
2224
|
entry["status"] = "conflicted"
|
|
1914
2225
|
lifecycle = entry.setdefault("lifecycle", {})
|
|
@@ -1919,6 +2230,8 @@ def mark_conflict(left: dict[str, Any], right: dict[str, Any]) -> None:
|
|
|
1919
2230
|
reason = "potential conflict with: " + other["id"]
|
|
1920
2231
|
if reason not in reasons:
|
|
1921
2232
|
reasons.append(reason)
|
|
2233
|
+
if evidence is not None:
|
|
2234
|
+
lifecycle.setdefault("conflictEvidence", {})[other["id"]] = evidence
|
|
1922
2235
|
lifecycle["lastCheckedAt"] = now_iso()
|
|
1923
2236
|
|
|
1924
2237
|
|
|
@@ -1926,8 +2239,9 @@ def mark_conflicting_generated_entries(entries: list[dict[str, Any]]) -> None:
|
|
|
1926
2239
|
ordered = sorted(entries, key=archive_sort_key)
|
|
1927
2240
|
for idx, left in enumerate(ordered):
|
|
1928
2241
|
for right in ordered[idx + 1 :]:
|
|
2242
|
+
evidence = conflict_evidence(left, right)
|
|
1929
2243
|
if entries_conflict(left, right):
|
|
1930
|
-
mark_conflict(left, right)
|
|
2244
|
+
mark_conflict(left, right, evidence)
|
|
1931
2245
|
|
|
1932
2246
|
|
|
1933
2247
|
def number_value(value: Any) -> int | None:
|
|
@@ -2523,6 +2837,9 @@ def compute_inputs_hash_from_preserved(
|
|
|
2523
2837
|
schema_fingerprint = json.dumps(
|
|
2524
2838
|
{
|
|
2525
2839
|
"entrySchemaVersion": 1,
|
|
2840
|
+
"entryIdentitySchemaVersion": ENTRY_ID_SCHEMA_VERSION,
|
|
2841
|
+
"archiveCacheSchemaVersion": ARCHIVE_CACHE_SCHEMA_VERSION,
|
|
2842
|
+
"extractorSchemaVersion": EXTRACTOR_SCHEMA_VERSION,
|
|
2526
2843
|
"indexSchemaVersion": 1,
|
|
2527
2844
|
"sqliteSchemaVersion": SQLITE_SCHEMA_VERSION,
|
|
2528
2845
|
},
|
|
@@ -2703,6 +3020,52 @@ def restore_unchanged_entry_state(
|
|
|
2703
3020
|
entry["lifecycle"] = json_clone(previous.get("lifecycle", entry.get("lifecycle", {})))
|
|
2704
3021
|
|
|
2705
3022
|
|
|
3023
|
+
def deduplicate_normalized_entries(
|
|
3024
|
+
entries: list[dict[str, Any]],
|
|
3025
|
+
) -> dict[str, Any]:
|
|
3026
|
+
exact_seen: set[str] = set()
|
|
3027
|
+
normalized_seen: set[str] = set()
|
|
3028
|
+
normalized_body_seen: set[str] = set()
|
|
3029
|
+
unique: list[dict[str, Any]] = []
|
|
3030
|
+
exact_removed = 0
|
|
3031
|
+
normalized_removed = 0
|
|
3032
|
+
for entry in entries:
|
|
3033
|
+
archive = str((entry.get("source") or {}).get("archive") or "")
|
|
3034
|
+
entry_type = str(entry.get("type") or "")
|
|
3035
|
+
exact = json.dumps(
|
|
3036
|
+
(entry_type, entry.get("title"), entry.get("body"), archive),
|
|
3037
|
+
ensure_ascii=False,
|
|
3038
|
+
sort_keys=True,
|
|
3039
|
+
)
|
|
3040
|
+
normalized_title = normalize_knowledge_text(entry.get("title"))
|
|
3041
|
+
normalized_body = normalize_knowledge_text(entry.get("body"))
|
|
3042
|
+
normalized = json.dumps(
|
|
3043
|
+
(archive, entry_type, normalized_title, normalized_body),
|
|
3044
|
+
ensure_ascii=False,
|
|
3045
|
+
sort_keys=True,
|
|
3046
|
+
)
|
|
3047
|
+
body_only = json.dumps(
|
|
3048
|
+
(archive, entry_type, normalized_body),
|
|
3049
|
+
ensure_ascii=False,
|
|
3050
|
+
sort_keys=True,
|
|
3051
|
+
)
|
|
3052
|
+
if exact in exact_seen:
|
|
3053
|
+
exact_removed += 1
|
|
3054
|
+
continue
|
|
3055
|
+
if normalized in normalized_seen or body_only in normalized_body_seen:
|
|
3056
|
+
normalized_removed += 1
|
|
3057
|
+
continue
|
|
3058
|
+
exact_seen.add(exact)
|
|
3059
|
+
normalized_seen.add(normalized)
|
|
3060
|
+
normalized_body_seen.add(body_only)
|
|
3061
|
+
unique.append(entry)
|
|
3062
|
+
return {
|
|
3063
|
+
"entries": unique,
|
|
3064
|
+
"exactDuplicatesRemoved": exact_removed,
|
|
3065
|
+
"normalizedDuplicatesRemoved": normalized_removed,
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
|
|
2706
3069
|
def build_index(
|
|
2707
3070
|
project: Path,
|
|
2708
3071
|
incremental: bool = True,
|
|
@@ -2890,22 +3253,13 @@ def build_index(
|
|
|
2890
3253
|
ingest_mode["activeAutoDemoted"] += publication_reconciliation["demoted"]
|
|
2891
3254
|
ingest_mode["entriesWritten"] += publication_reconciliation["written"]
|
|
2892
3255
|
|
|
2893
|
-
|
|
2894
|
-
deduped
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
entry["body"],
|
|
2901
|
-
entry["source"]["archive"],
|
|
2902
|
-
)
|
|
2903
|
-
fp = json.dumps(fingerprint, ensure_ascii=False, sort_keys=True)
|
|
2904
|
-
if fp in seen:
|
|
2905
|
-
duplicates += 1
|
|
2906
|
-
continue
|
|
2907
|
-
seen.add(fp)
|
|
2908
|
-
deduped.append(entry)
|
|
3256
|
+
normalized_dedupe = deduplicate_normalized_entries(entries)
|
|
3257
|
+
deduped = normalized_dedupe["entries"]
|
|
3258
|
+
exact_duplicates = normalized_dedupe["exactDuplicatesRemoved"]
|
|
3259
|
+
normalized_duplicates = normalized_dedupe["normalizedDuplicatesRemoved"]
|
|
3260
|
+
duplicates = exact_duplicates + normalized_duplicates
|
|
3261
|
+
ingest_mode["exactDuplicatesRemoved"] = exact_duplicates
|
|
3262
|
+
ingest_mode["normalizedDuplicatesRemoved"] = normalized_duplicates
|
|
2909
3263
|
|
|
2910
3264
|
freshness_entries = [
|
|
2911
3265
|
entry
|
|
@@ -3179,27 +3533,32 @@ def audit_entries(project: Path, limit: int = 10) -> dict[str, Any]:
|
|
|
3179
3533
|
if not sync["upToDate"]:
|
|
3180
3534
|
build_index(project)
|
|
3181
3535
|
entries = load_indexed_entries(project)
|
|
3182
|
-
|
|
3536
|
+
all_candidates = sorted(
|
|
3183
3537
|
[entry for entry in entries if entry["status"] == "candidate"],
|
|
3184
3538
|
key=lambda entry: score_entry(entry, " ".join(entry.get("keywords") or [])),
|
|
3185
3539
|
reverse=True,
|
|
3186
|
-
)
|
|
3187
|
-
|
|
3540
|
+
)
|
|
3541
|
+
candidates = all_candidates[:limit]
|
|
3542
|
+
all_stale = sorted(
|
|
3188
3543
|
[entry for entry in entries if entry["status"] == "stale"],
|
|
3189
3544
|
key=lambda entry: (len(entry.get("lifecycle", {}).get("staleReasons") or []), score_entry(entry, "")),
|
|
3190
3545
|
reverse=True,
|
|
3191
|
-
)
|
|
3192
|
-
|
|
3546
|
+
)
|
|
3547
|
+
stale = all_stale[:limit]
|
|
3548
|
+
all_superseded = sorted(
|
|
3193
3549
|
[entry for entry in entries if entry["status"] == "superseded"],
|
|
3194
3550
|
key=archive_sort_key,
|
|
3195
3551
|
reverse=True,
|
|
3196
|
-
)
|
|
3197
|
-
|
|
3552
|
+
)
|
|
3553
|
+
superseded = all_superseded[:limit]
|
|
3554
|
+
all_conflicted = sorted(
|
|
3198
3555
|
[entry for entry in entries if entry["status"] == "conflicted"],
|
|
3199
3556
|
key=archive_sort_key,
|
|
3200
3557
|
reverse=True,
|
|
3201
|
-
)
|
|
3558
|
+
)
|
|
3559
|
+
conflicted = all_conflicted[:limit]
|
|
3202
3560
|
active_review = active_review_items(entries, limit)
|
|
3561
|
+
judge = collect_judge_work(entries, limit=limit)
|
|
3203
3562
|
report_path = knowledge / "reports" / f"audit-report-{timestamp()}.md"
|
|
3204
3563
|
lines = [
|
|
3205
3564
|
"# Harness Knowledge Audit Report",
|
|
@@ -3245,6 +3604,14 @@ def audit_entries(project: Path, limit: int = 10) -> dict[str, Any]:
|
|
|
3245
3604
|
"supersededReview": [audit_summary(entry) for entry in superseded],
|
|
3246
3605
|
"conflictReview": [audit_summary(entry) for entry in conflicted],
|
|
3247
3606
|
"activeReview": [audit_summary(entry) for entry in active_review],
|
|
3607
|
+
"counts": {
|
|
3608
|
+
**judge["counts"],
|
|
3609
|
+
"totalStaleEntries": len(all_stale),
|
|
3610
|
+
"totalSupersededEntries": len(all_superseded),
|
|
3611
|
+
"displayedStaleEntries": len(stale),
|
|
3612
|
+
"displayedSupersededEntries": len(superseded),
|
|
3613
|
+
"displayedConflictEntries": len(conflicted),
|
|
3614
|
+
},
|
|
3248
3615
|
}
|
|
3249
3616
|
|
|
3250
3617
|
|
|
@@ -3344,6 +3711,12 @@ def make_manifest(
|
|
|
3344
3711
|
"stats": stats,
|
|
3345
3712
|
"byType": by_type,
|
|
3346
3713
|
"duplicatesSkipped": duplicates,
|
|
3714
|
+
"health": summarize_knowledge_health(
|
|
3715
|
+
entries,
|
|
3716
|
+
duplicate_groups=int(
|
|
3717
|
+
(ingest_mode or {}).get("normalizedDuplicatesRemoved") or 0
|
|
3718
|
+
),
|
|
3719
|
+
),
|
|
3347
3720
|
"ingestMode": ingest_mode or {
|
|
3348
3721
|
"incremental": False,
|
|
3349
3722
|
"archivesExtracted": len(summary_paths) - len(failures),
|
|
@@ -3985,11 +4358,17 @@ def suggest_validators(
|
|
|
3985
4358
|
if not (knowledge / "index.json").exists():
|
|
3986
4359
|
build_index(project)
|
|
3987
4360
|
selected_statuses = statuses or ["active", "candidate"]
|
|
3988
|
-
|
|
4361
|
+
eligible: list[dict[str, Any]] = []
|
|
4362
|
+
unavailable = 0
|
|
4363
|
+
covered = 0
|
|
3989
4364
|
applied = 0
|
|
3990
4365
|
for entry_path, entry in load_entry_files(knowledge, selected_statuses):
|
|
4366
|
+
if isinstance(entry.get("validators"), list) and entry["validators"]:
|
|
4367
|
+
covered += 1
|
|
4368
|
+
continue
|
|
3991
4369
|
validators = suggest_validators_for_entry(project, entry)
|
|
3992
4370
|
if not validators:
|
|
4371
|
+
unavailable += 1
|
|
3993
4372
|
continue
|
|
3994
4373
|
item = {
|
|
3995
4374
|
"id": entry["id"],
|
|
@@ -3998,21 +4377,27 @@ def suggest_validators(
|
|
|
3998
4377
|
"path": str(entry_path),
|
|
3999
4378
|
"validators": validators,
|
|
4000
4379
|
}
|
|
4001
|
-
|
|
4380
|
+
eligible.append(item)
|
|
4381
|
+
selected = eligible[: max(0, limit)]
|
|
4382
|
+
for item in selected:
|
|
4002
4383
|
if apply:
|
|
4003
|
-
entry["
|
|
4004
|
-
|
|
4384
|
+
entry = read_json(Path(item["path"]))
|
|
4385
|
+
entry["validators"] = item["validators"]
|
|
4386
|
+
write_json(Path(item["path"]), entry)
|
|
4005
4387
|
applied += 1
|
|
4006
|
-
if len(suggestions) >= max(1, limit):
|
|
4007
|
-
break
|
|
4008
4388
|
summary = {
|
|
4009
4389
|
"generatedAt": now_iso(),
|
|
4010
4390
|
"project": str(project),
|
|
4011
4391
|
"statuses": selected_statuses,
|
|
4012
4392
|
"limit": limit,
|
|
4013
|
-
"
|
|
4393
|
+
"eligible": len(eligible),
|
|
4394
|
+
"selected": len(selected),
|
|
4395
|
+
"suggested": len(selected),
|
|
4014
4396
|
"applied": applied,
|
|
4015
|
-
"
|
|
4397
|
+
"remaining": max(0, len(eligible) - len(selected)),
|
|
4398
|
+
"unavailable": unavailable,
|
|
4399
|
+
"covered": covered,
|
|
4400
|
+
"entries": selected,
|
|
4016
4401
|
}
|
|
4017
4402
|
report = write_validator_suggestions_report(knowledge, summary)
|
|
4018
4403
|
if apply and applied:
|
|
@@ -4054,7 +4439,10 @@ def auto_knowledge(
|
|
|
4054
4439
|
verification = verify_knowledge(project)
|
|
4055
4440
|
audit = audit_entries(project, limit=audit_limit)
|
|
4056
4441
|
ingest_mode = sync.get("index", {}).get("ingestMode", {}) if isinstance(sync.get("index"), dict) else {}
|
|
4442
|
+
judge_counts = audit.get("counts") or {}
|
|
4443
|
+
final_status = sync_status(project, check=True)
|
|
4057
4444
|
return {
|
|
4445
|
+
"command": "auto",
|
|
4058
4446
|
"project": str(project),
|
|
4059
4447
|
"generatedAt": now_iso(),
|
|
4060
4448
|
"mode": {
|
|
@@ -4065,6 +4453,10 @@ def auto_knowledge(
|
|
|
4065
4453
|
"incremental": incremental,
|
|
4066
4454
|
},
|
|
4067
4455
|
"config": config_summary,
|
|
4456
|
+
"ok": final_status["freshness"]["status"] == "OK",
|
|
4457
|
+
"upToDate": final_status["freshness"]["status"] == "OK",
|
|
4458
|
+
"freshness": final_status["freshness"],
|
|
4459
|
+
"health": final_status["health"],
|
|
4068
4460
|
"sync": {
|
|
4069
4461
|
"upToDate": sync["upToDate"],
|
|
4070
4462
|
"action": sync["action"],
|
|
@@ -4073,8 +4465,13 @@ def auto_knowledge(
|
|
|
4073
4465
|
"paths": sync["paths"],
|
|
4074
4466
|
},
|
|
4075
4467
|
"suggestions": {
|
|
4468
|
+
"eligible": suggestions["eligible"],
|
|
4469
|
+
"selected": suggestions["selected"],
|
|
4076
4470
|
"suggested": suggestions["suggested"],
|
|
4077
4471
|
"applied": suggestions["applied"],
|
|
4472
|
+
"remaining": suggestions["remaining"],
|
|
4473
|
+
"unavailable": suggestions["unavailable"],
|
|
4474
|
+
"covered": suggestions["covered"],
|
|
4078
4475
|
"report": suggestions["report"],
|
|
4079
4476
|
},
|
|
4080
4477
|
"verification": {
|
|
@@ -4092,7 +4489,9 @@ def auto_knowledge(
|
|
|
4092
4489
|
"supersededReview": len(audit["supersededReview"]),
|
|
4093
4490
|
"conflictReview": len(audit["conflictReview"]),
|
|
4094
4491
|
"activeReview": len(audit["activeReview"]),
|
|
4492
|
+
"counts": audit.get("counts", {}),
|
|
4095
4493
|
},
|
|
4494
|
+
"judge": judge_counts,
|
|
4096
4495
|
"lifecycle": {
|
|
4097
4496
|
"validatorsApplied": suggestions["applied"],
|
|
4098
4497
|
"candidateAutoPromoted": ingest_mode.get(
|
|
@@ -4100,7 +4499,15 @@ def auto_knowledge(
|
|
|
4100
4499
|
),
|
|
4101
4500
|
"activeAutoDemoted": ingest_mode.get("activeAutoDemoted", 0),
|
|
4102
4501
|
"validationAutoDemoted": ingest_mode.get("validationAutoDemoted", verification["autoDemoted"]),
|
|
4103
|
-
"pendingAgentJudge":
|
|
4502
|
+
"pendingAgentJudge": judge_counts.get("requiredDecisionCount", 0),
|
|
4503
|
+
"pendingAgentJudgeSummary": {
|
|
4504
|
+
"requiredDecisionCount": judge_counts.get("requiredDecisionCount", 0),
|
|
4505
|
+
"pendingWorkItemCount": judge_counts.get("pendingWorkItemCount", 0),
|
|
4506
|
+
"previewCount": judge_counts.get("previewCount", 0),
|
|
4507
|
+
"previewEntryCount": judge_counts.get("previewEntryCount", 0),
|
|
4508
|
+
"deferredByLimitCount": judge_counts.get("deferredByLimitCount", 0),
|
|
4509
|
+
"quarantinedCount": judge_counts.get("quarantinedCount", 0),
|
|
4510
|
+
},
|
|
4104
4511
|
},
|
|
4105
4512
|
}
|
|
4106
4513
|
|
|
@@ -4211,6 +4618,7 @@ def sync_status(
|
|
|
4211
4618
|
incremental: bool = True,
|
|
4212
4619
|
*,
|
|
4213
4620
|
progress: ProgressReporter | None = None,
|
|
4621
|
+
check: bool = False,
|
|
4214
4622
|
) -> dict[str, Any]:
|
|
4215
4623
|
project = project.resolve()
|
|
4216
4624
|
snapshot = build_snapshot(project)
|
|
@@ -4269,6 +4677,7 @@ def sync_status(
|
|
|
4269
4677
|
)
|
|
4270
4678
|
action = "ingested"
|
|
4271
4679
|
reasons = []
|
|
4680
|
+
index = refreshed
|
|
4272
4681
|
|
|
4273
4682
|
# HH-KNOW-20260730-001: an up-to-date index is not the whole story --
|
|
4274
4683
|
# maintenance-outbox items enqueued by `harness archive` still need to be
|
|
@@ -4288,7 +4697,27 @@ def sync_status(
|
|
|
4288
4697
|
"maintain --project <project> --drain"
|
|
4289
4698
|
)
|
|
4290
4699
|
|
|
4291
|
-
if
|
|
4700
|
+
if check:
|
|
4701
|
+
pending_count = len(list((outbox_root / "pending").glob("*.json"))) if outbox_root.is_dir() else 0
|
|
4702
|
+
failed_count = len(list((outbox_root / "failed").glob("*.json"))) if outbox_root.is_dir() else 0
|
|
4703
|
+
running_count = len(list((outbox_root / "running").glob("*.json"))) if outbox_root.is_dir() else 0
|
|
4704
|
+
outbox_ok = pending_count == 0 and failed_count == 0 and running_count == 0
|
|
4705
|
+
maintenance = {
|
|
4706
|
+
"attempted": False,
|
|
4707
|
+
"skipped": True,
|
|
4708
|
+
"readOnly": True,
|
|
4709
|
+
"skippedReason": "read-only check",
|
|
4710
|
+
"ok": outbox_ok,
|
|
4711
|
+
"pending": pending_count,
|
|
4712
|
+
"failed": failed_count,
|
|
4713
|
+
"running": running_count,
|
|
4714
|
+
}
|
|
4715
|
+
if not outbox_ok:
|
|
4716
|
+
next_action = (
|
|
4717
|
+
"Read-only check found maintenance work. Run the default auto workflow "
|
|
4718
|
+
"or `hunter-harness sync --apply safe`."
|
|
4719
|
+
)
|
|
4720
|
+
elif index_current:
|
|
4292
4721
|
drain_result = drain_maintenance_outbox(project)
|
|
4293
4722
|
outbox_ok = bool(drain_result.get("ok")) and drain_result.get("remaining", 0) == 0
|
|
4294
4723
|
maintenance = {
|
|
@@ -4326,6 +4755,24 @@ def sync_status(
|
|
|
4326
4755
|
|
|
4327
4756
|
ok = index_current and bool(maintenance.get("ok"))
|
|
4328
4757
|
|
|
4758
|
+
indexed_count = (
|
|
4759
|
+
int((index.get("archives") or {}).get("indexed") or 0)
|
|
4760
|
+
if isinstance(index, dict)
|
|
4761
|
+
else 0
|
|
4762
|
+
)
|
|
4763
|
+
freshness = {
|
|
4764
|
+
"status": "OK" if index_current else "WARN",
|
|
4765
|
+
"archivesScanned": len(current_records),
|
|
4766
|
+
"archivesIndexed": indexed_count,
|
|
4767
|
+
"headCurrent": index_current,
|
|
4768
|
+
}
|
|
4769
|
+
entries = [entry for _, entry in load_entry_files(knowledge)]
|
|
4770
|
+
ingest_mode = index.get("ingestMode", {}) if isinstance(index, dict) else {}
|
|
4771
|
+
duplicate_groups = int(ingest_mode.get("normalizedDuplicatesRemoved") or 0)
|
|
4772
|
+
health = summarize_knowledge_health(
|
|
4773
|
+
entries,
|
|
4774
|
+
duplicate_groups=duplicate_groups,
|
|
4775
|
+
)
|
|
4329
4776
|
result = {
|
|
4330
4777
|
"project": str(project),
|
|
4331
4778
|
"upToDate": not reasons,
|
|
@@ -4337,6 +4784,8 @@ def sync_status(
|
|
|
4337
4784
|
"sqlite": str(sqlite_path),
|
|
4338
4785
|
},
|
|
4339
4786
|
"maintenance": maintenance,
|
|
4787
|
+
"freshness": freshness,
|
|
4788
|
+
"health": health,
|
|
4340
4789
|
"ok": ok,
|
|
4341
4790
|
"nextAction": next_action,
|
|
4342
4791
|
}
|
|
@@ -5169,6 +5618,250 @@ def reverify_stale_knowledge(project: Path) -> dict[str, Any]:
|
|
|
5169
5618
|
}
|
|
5170
5619
|
|
|
5171
5620
|
|
|
5621
|
+
def review_evidence_fingerprints(entry: dict[str, Any]) -> dict[str, str]:
|
|
5622
|
+
source = entry.get("source") or {}
|
|
5623
|
+
lifecycle = entry.get("lifecycle") or {}
|
|
5624
|
+
return {
|
|
5625
|
+
"summarySha256": str(source.get("summarySha256") or ""),
|
|
5626
|
+
"entryBodySha256": sha256_text(normalize_knowledge_text(entry.get("body"))),
|
|
5627
|
+
"publicationGateFingerprint": sha256_text(
|
|
5628
|
+
json.dumps(
|
|
5629
|
+
{
|
|
5630
|
+
"publishBlocked": lifecycle.get("publishBlocked") or [],
|
|
5631
|
+
"publicationState": lifecycle.get("publicationState"),
|
|
5632
|
+
"finalStatus": source.get("finalStatus"),
|
|
5633
|
+
},
|
|
5634
|
+
ensure_ascii=False,
|
|
5635
|
+
sort_keys=True,
|
|
5636
|
+
separators=(",", ":"),
|
|
5637
|
+
)
|
|
5638
|
+
),
|
|
5639
|
+
"validatorFingerprint": sha256_text(
|
|
5640
|
+
json.dumps(
|
|
5641
|
+
entry.get("validators") or [],
|
|
5642
|
+
ensure_ascii=False,
|
|
5643
|
+
sort_keys=True,
|
|
5644
|
+
separators=(",", ":"),
|
|
5645
|
+
)
|
|
5646
|
+
),
|
|
5647
|
+
"conflictFingerprint": sha256_text(
|
|
5648
|
+
json.dumps(
|
|
5649
|
+
{
|
|
5650
|
+
"status": entry.get("status"),
|
|
5651
|
+
"conflictsWith": sorted(lifecycle.get("conflictsWith") or []),
|
|
5652
|
+
"conflictEvidence": lifecycle.get("conflictEvidence") or [],
|
|
5653
|
+
},
|
|
5654
|
+
ensure_ascii=False,
|
|
5655
|
+
sort_keys=True,
|
|
5656
|
+
separators=(",", ":"),
|
|
5657
|
+
)
|
|
5658
|
+
),
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
|
|
5662
|
+
def defer_is_current(entry: dict[str, Any]) -> bool:
|
|
5663
|
+
lifecycle = entry.get("lifecycle") or {}
|
|
5664
|
+
if lifecycle.get("judgeAction") != "defer":
|
|
5665
|
+
return False
|
|
5666
|
+
recorded = lifecycle.get("deferEvidence")
|
|
5667
|
+
if not isinstance(recorded, dict):
|
|
5668
|
+
return False
|
|
5669
|
+
if recorded != review_evidence_fingerprints(entry):
|
|
5670
|
+
return False
|
|
5671
|
+
review_after = str(lifecycle.get("reviewAfter") or "")
|
|
5672
|
+
return bool(review_after and review_after > dt.date.today().isoformat())
|
|
5673
|
+
|
|
5674
|
+
|
|
5675
|
+
def judge_entry_payload(entry: dict[str, Any]) -> dict[str, Any]:
|
|
5676
|
+
return {
|
|
5677
|
+
"id": entry.get("id"),
|
|
5678
|
+
"type": entry.get("type"),
|
|
5679
|
+
"status": entry.get("status"),
|
|
5680
|
+
"title": entry.get("title"),
|
|
5681
|
+
"body": entry.get("body"),
|
|
5682
|
+
"summary": entry.get("summary"),
|
|
5683
|
+
"source": entry.get("source"),
|
|
5684
|
+
"scope": entry.get("scope"),
|
|
5685
|
+
"confidence": entry.get("confidence"),
|
|
5686
|
+
"lifecycle": {
|
|
5687
|
+
"conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
|
|
5688
|
+
"conflictEvidence": entry.get("lifecycle", {}).get("conflictEvidence") or {},
|
|
5689
|
+
"staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
|
|
5690
|
+
},
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5693
|
+
|
|
5694
|
+
def collect_judge_work(
|
|
5695
|
+
entries: list[dict[str, Any]],
|
|
5696
|
+
limit: int = 100,
|
|
5697
|
+
) -> dict[str, Any]:
|
|
5698
|
+
by_id = {
|
|
5699
|
+
str(entry.get("id")): entry
|
|
5700
|
+
for entry in entries
|
|
5701
|
+
if entry.get("id")
|
|
5702
|
+
}
|
|
5703
|
+
blocked_ids = {
|
|
5704
|
+
entry_id
|
|
5705
|
+
for entry_id, entry in by_id.items()
|
|
5706
|
+
if (entry.get("lifecycle") or {}).get("publishBlocked")
|
|
5707
|
+
}
|
|
5708
|
+
all_conflicts: list[tuple[tuple[str, str], dict[str, Any], dict[str, Any] | None]] = []
|
|
5709
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
5710
|
+
for entry in entries:
|
|
5711
|
+
if entry.get("status") != "conflicted":
|
|
5712
|
+
continue
|
|
5713
|
+
for other_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
|
|
5714
|
+
pair = tuple(sorted([str(entry["id"]), str(other_id)]))
|
|
5715
|
+
if pair in seen_pairs:
|
|
5716
|
+
continue
|
|
5717
|
+
seen_pairs.add(pair)
|
|
5718
|
+
all_conflicts.append((pair, entry, by_id.get(str(other_id))))
|
|
5719
|
+
|
|
5720
|
+
eligible_conflicts = []
|
|
5721
|
+
for pair, left, right in all_conflicts:
|
|
5722
|
+
pair_entries = [item for item in (left, right) if item is not None]
|
|
5723
|
+
if any(entry_id in blocked_ids for entry_id in pair):
|
|
5724
|
+
continue
|
|
5725
|
+
if pair_entries and all(
|
|
5726
|
+
(item.get("lifecycle") or {}).get("judgeAction") == "keep-conflict"
|
|
5727
|
+
or defer_is_current(item)
|
|
5728
|
+
for item in pair_entries
|
|
5729
|
+
):
|
|
5730
|
+
continue
|
|
5731
|
+
eligible_conflicts.append(
|
|
5732
|
+
{
|
|
5733
|
+
"kind": "conflict",
|
|
5734
|
+
"ids": list(pair),
|
|
5735
|
+
"entries": [judge_entry_payload(item) for item in pair_entries],
|
|
5736
|
+
}
|
|
5737
|
+
)
|
|
5738
|
+
|
|
5739
|
+
candidates = [entry for entry in entries if entry.get("status") == "candidate"]
|
|
5740
|
+
eligible_candidates = [
|
|
5741
|
+
entry
|
|
5742
|
+
for entry in candidates
|
|
5743
|
+
if str(entry.get("id")) not in blocked_ids and not defer_is_current(entry)
|
|
5744
|
+
]
|
|
5745
|
+
bounded_limit = max(0, limit)
|
|
5746
|
+
displayed_conflicts = eligible_conflicts[:bounded_limit]
|
|
5747
|
+
displayed_candidates = eligible_candidates[:bounded_limit]
|
|
5748
|
+
conflict_entry_ids = {
|
|
5749
|
+
entry_id
|
|
5750
|
+
for item in eligible_conflicts
|
|
5751
|
+
for entry_id in item["ids"]
|
|
5752
|
+
}
|
|
5753
|
+
required_ids = conflict_entry_ids | {
|
|
5754
|
+
str(entry["id"]) for entry in eligible_candidates
|
|
5755
|
+
}
|
|
5756
|
+
displayed_ids = {
|
|
5757
|
+
entry_id
|
|
5758
|
+
for item in displayed_conflicts
|
|
5759
|
+
for entry_id in item["ids"]
|
|
5760
|
+
} | {
|
|
5761
|
+
str(entry["id"]) for entry in displayed_candidates
|
|
5762
|
+
}
|
|
5763
|
+
pending_work_item_count = len(eligible_conflicts) + len(eligible_candidates)
|
|
5764
|
+
preview_count = len(displayed_conflicts) + len(displayed_candidates)
|
|
5765
|
+
all_conflict_entry_ids = {
|
|
5766
|
+
entry_id for pair, _, _ in all_conflicts for entry_id in pair
|
|
5767
|
+
}
|
|
5768
|
+
return {
|
|
5769
|
+
"conflicts": displayed_conflicts,
|
|
5770
|
+
"promoteCandidates": [
|
|
5771
|
+
{"kind": "promote-candidate", **judge_entry_payload(entry)}
|
|
5772
|
+
for entry in displayed_candidates
|
|
5773
|
+
],
|
|
5774
|
+
"counts": {
|
|
5775
|
+
"totalCandidateEntries": len(candidates),
|
|
5776
|
+
"totalConflictGroups": len(all_conflicts),
|
|
5777
|
+
"totalConflictEntries": len(all_conflict_entry_ids),
|
|
5778
|
+
"requiredDecisionCount": len(required_ids),
|
|
5779
|
+
"pendingWorkItemCount": pending_work_item_count,
|
|
5780
|
+
"previewCount": preview_count,
|
|
5781
|
+
"previewEntryCount": len(displayed_ids),
|
|
5782
|
+
"deferredByLimitCount": max(0, pending_work_item_count - preview_count),
|
|
5783
|
+
"displayedCandidateEntries": len(displayed_candidates),
|
|
5784
|
+
"displayedConflictGroups": len(displayed_conflicts),
|
|
5785
|
+
"quarantinedEntries": len(blocked_ids),
|
|
5786
|
+
"quarantinedCount": len(blocked_ids),
|
|
5787
|
+
},
|
|
5788
|
+
}
|
|
5789
|
+
|
|
5790
|
+
|
|
5791
|
+
def summarize_knowledge_health(
|
|
5792
|
+
entries: list[dict[str, Any]],
|
|
5793
|
+
*,
|
|
5794
|
+
duplicate_groups: int = 0,
|
|
5795
|
+
) -> dict[str, Any]:
|
|
5796
|
+
lifecycle_names = [
|
|
5797
|
+
"active",
|
|
5798
|
+
"candidate",
|
|
5799
|
+
"stale",
|
|
5800
|
+
"superseded",
|
|
5801
|
+
"conflicted",
|
|
5802
|
+
]
|
|
5803
|
+
content_lifecycle = {
|
|
5804
|
+
status: sum(1 for entry in entries if entry.get("status") == status)
|
|
5805
|
+
for status in lifecycle_names
|
|
5806
|
+
}
|
|
5807
|
+
review_state = {
|
|
5808
|
+
"pending": 0,
|
|
5809
|
+
"deferred": 0,
|
|
5810
|
+
"decided": 0,
|
|
5811
|
+
"quarantined": 0,
|
|
5812
|
+
}
|
|
5813
|
+
publication_state = {"publishable": 0, "blocked": 0, "unverified": 0}
|
|
5814
|
+
validation_state = {"covered": 0, "uncovered": 0, "failed": 0}
|
|
5815
|
+
for entry in entries:
|
|
5816
|
+
lifecycle = entry.get("lifecycle") or {}
|
|
5817
|
+
blocked = bool(lifecycle.get("publishBlocked"))
|
|
5818
|
+
if blocked:
|
|
5819
|
+
review_state["quarantined"] += 1
|
|
5820
|
+
publication_state["blocked"] += 1
|
|
5821
|
+
elif lifecycle.get("publicationState") == "unverified":
|
|
5822
|
+
publication_state["unverified"] += 1
|
|
5823
|
+
else:
|
|
5824
|
+
publication_state["publishable"] += 1
|
|
5825
|
+
|
|
5826
|
+
if not blocked and defer_is_current(entry):
|
|
5827
|
+
review_state["deferred"] += 1
|
|
5828
|
+
elif lifecycle.get("judgeAction") and lifecycle.get("judgeAction") != "defer":
|
|
5829
|
+
review_state["decided"] += 1
|
|
5830
|
+
elif not blocked and entry.get("status") in {"candidate", "conflicted"}:
|
|
5831
|
+
review_state["pending"] += 1
|
|
5832
|
+
|
|
5833
|
+
validation = lifecycle.get("validation") or {}
|
|
5834
|
+
if validation.get("status") == "failed":
|
|
5835
|
+
validation_state["failed"] += 1
|
|
5836
|
+
if isinstance(entry.get("validators"), list) and entry["validators"]:
|
|
5837
|
+
validation_state["covered"] += 1
|
|
5838
|
+
else:
|
|
5839
|
+
validation_state["uncovered"] += 1
|
|
5840
|
+
|
|
5841
|
+
status = "OK"
|
|
5842
|
+
if (
|
|
5843
|
+
publication_state["blocked"] > 0
|
|
5844
|
+
or content_lifecycle["conflicted"] > 0
|
|
5845
|
+
or validation_state["failed"] > 0
|
|
5846
|
+
or duplicate_groups > 0
|
|
5847
|
+
):
|
|
5848
|
+
status = "WARN"
|
|
5849
|
+
elif (
|
|
5850
|
+
review_state["pending"] > 0
|
|
5851
|
+
or review_state["deferred"] > 0
|
|
5852
|
+
or validation_state["uncovered"] > 0
|
|
5853
|
+
):
|
|
5854
|
+
status = "ADVISORY"
|
|
5855
|
+
return {
|
|
5856
|
+
"status": status,
|
|
5857
|
+
"contentLifecycle": content_lifecycle,
|
|
5858
|
+
"reviewState": review_state,
|
|
5859
|
+
"publicationState": publication_state,
|
|
5860
|
+
"validationState": validation_state,
|
|
5861
|
+
"duplicateGroups": duplicate_groups,
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5864
|
+
|
|
5172
5865
|
def snapshot_entry_state(entry: dict[str, Any]) -> dict[str, Any]:
|
|
5173
5866
|
return json_clone(
|
|
5174
5867
|
{
|
|
@@ -5196,104 +5889,33 @@ def judge_export(project: Path) -> dict[str, Any]:
|
|
|
5196
5889
|
if not (knowledge / "index.json").exists():
|
|
5197
5890
|
build_index(project)
|
|
5198
5891
|
entries = [entry for _, entry in load_entry_files(knowledge)]
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
for other_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
|
|
5209
|
-
pair = tuple(sorted([entry["id"], str(other_id)]))
|
|
5210
|
-
if pair in seen_pairs:
|
|
5211
|
-
continue
|
|
5212
|
-
seen_pairs.add(pair)
|
|
5213
|
-
other = by_id.get(str(other_id))
|
|
5214
|
-
conflicts.append(
|
|
5215
|
-
{
|
|
5216
|
-
"kind": "conflict",
|
|
5217
|
-
"ids": list(pair),
|
|
5218
|
-
"entries": [
|
|
5219
|
-
{
|
|
5220
|
-
"id": entry["id"],
|
|
5221
|
-
"type": entry.get("type"),
|
|
5222
|
-
"status": entry.get("status"),
|
|
5223
|
-
"title": entry.get("title"),
|
|
5224
|
-
"body": entry.get("body"),
|
|
5225
|
-
"summary": entry.get("summary"),
|
|
5226
|
-
"source": entry.get("source"),
|
|
5227
|
-
"scope": entry.get("scope"),
|
|
5228
|
-
"lifecycle": {
|
|
5229
|
-
"conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
|
|
5230
|
-
"staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
|
|
5231
|
-
},
|
|
5232
|
-
},
|
|
5233
|
-
{
|
|
5234
|
-
"id": other.get("id") if other else other_id,
|
|
5235
|
-
"type": other.get("type") if other else None,
|
|
5236
|
-
"status": other.get("status") if other else None,
|
|
5237
|
-
"title": other.get("title") if other else None,
|
|
5238
|
-
"body": other.get("body") if other else None,
|
|
5239
|
-
"summary": other.get("summary") if other else None,
|
|
5240
|
-
"source": other.get("source") if other else None,
|
|
5241
|
-
"scope": other.get("scope") if other else None,
|
|
5242
|
-
"lifecycle": {
|
|
5243
|
-
"conflictsWith": (other.get("lifecycle", {}) or {}).get("conflictsWith") or [],
|
|
5244
|
-
"staleReasons": (other.get("lifecycle", {}) or {}).get("staleReasons") or [],
|
|
5245
|
-
}
|
|
5246
|
-
if other
|
|
5247
|
-
else {},
|
|
5248
|
-
},
|
|
5249
|
-
],
|
|
5250
|
-
}
|
|
5251
|
-
)
|
|
5252
|
-
|
|
5253
|
-
promote_candidates: list[dict[str, Any]] = []
|
|
5254
|
-
for entry in entries:
|
|
5255
|
-
if entry.get("status") != "candidate":
|
|
5256
|
-
continue
|
|
5257
|
-
if entry.get("lifecycle", {}).get("judgeAction") == "defer":
|
|
5258
|
-
review_after = str(entry.get("lifecycle", {}).get("reviewAfter") or "")
|
|
5259
|
-
if not review_after or review_after > dt.date.today().isoformat():
|
|
5260
|
-
continue
|
|
5261
|
-
promote_candidates.append(
|
|
5262
|
-
{
|
|
5263
|
-
"kind": "promote-candidate",
|
|
5264
|
-
"id": entry["id"],
|
|
5265
|
-
"type": entry.get("type"),
|
|
5266
|
-
"status": entry.get("status"),
|
|
5267
|
-
"title": entry.get("title"),
|
|
5268
|
-
"body": entry.get("body"),
|
|
5269
|
-
"summary": entry.get("summary"),
|
|
5270
|
-
"source": entry.get("source"),
|
|
5271
|
-
"scope": entry.get("scope"),
|
|
5272
|
-
"confidence": entry.get("confidence"),
|
|
5273
|
-
"lifecycle": {
|
|
5274
|
-
"staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
|
|
5275
|
-
"conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
|
|
5276
|
-
},
|
|
5277
|
-
}
|
|
5278
|
-
)
|
|
5892
|
+
max_candidates = judge_config(load_config(knowledge))["maxCandidatesPerRun"]
|
|
5893
|
+
work = collect_judge_work(entries, limit=max_candidates)
|
|
5894
|
+
counts = {
|
|
5895
|
+
**work["counts"],
|
|
5896
|
+
# Compatibility aliases retained for two releases.
|
|
5897
|
+
"conflicts": work["counts"]["displayedConflictGroups"],
|
|
5898
|
+
"promoteCandidates": work["counts"]["displayedCandidateEntries"],
|
|
5899
|
+
"pending": work["counts"]["pendingWorkItemCount"],
|
|
5900
|
+
}
|
|
5279
5901
|
|
|
5280
5902
|
payload = {
|
|
5281
5903
|
"schemaVersion": 1,
|
|
5282
5904
|
"generatedAt": now_iso(),
|
|
5283
5905
|
"project": str(project),
|
|
5284
5906
|
"manualReview": manual_review_enabled(project),
|
|
5285
|
-
"counts":
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
"pending": len(conflicts) + len(promote_candidates),
|
|
5289
|
-
},
|
|
5290
|
-
"conflicts": conflicts,
|
|
5291
|
-
"promoteCandidates": promote_candidates,
|
|
5907
|
+
"counts": counts,
|
|
5908
|
+
"conflicts": work["conflicts"],
|
|
5909
|
+
"promoteCandidates": work["promoteCandidates"],
|
|
5292
5910
|
"actions": sorted(JUDGE_ACTIONS),
|
|
5293
5911
|
}
|
|
5294
|
-
export_path =
|
|
5295
|
-
|
|
5912
|
+
export_path, digest = write_content_addressed_json_report(
|
|
5913
|
+
knowledge,
|
|
5914
|
+
"judge-export",
|
|
5915
|
+
payload,
|
|
5916
|
+
)
|
|
5296
5917
|
payload["exportPath"] = str(export_path)
|
|
5918
|
+
payload["exportSha256"] = digest
|
|
5297
5919
|
return payload
|
|
5298
5920
|
|
|
5299
5921
|
|
|
@@ -5370,6 +5992,7 @@ def apply_judge_decision(
|
|
|
5370
5992
|
lifecycle["judgeReason"] = reason
|
|
5371
5993
|
lifecycle["deferredAt"] = now_iso()
|
|
5372
5994
|
lifecycle["reviewAfter"] = decision.get("reviewAfter")
|
|
5995
|
+
lifecycle["deferEvidence"] = review_evidence_fingerprints(entry)
|
|
5373
5996
|
lifecycle["lastCheckedAt"] = now_iso()
|
|
5374
5997
|
relocate_entry_file(knowledge, entry, source_path)
|
|
5375
5998
|
path_by_id[entry["id"]] = (
|
|
@@ -5418,6 +6041,48 @@ def persist_judge_decisions(knowledge: Path, applied: list[dict[str, Any]]) -> P
|
|
|
5418
6041
|
return path
|
|
5419
6042
|
|
|
5420
6043
|
|
|
6044
|
+
def store_rollback_snapshot(
|
|
6045
|
+
knowledge: Path,
|
|
6046
|
+
snapshot: dict[str, Any],
|
|
6047
|
+
) -> dict[str, str]:
|
|
6048
|
+
raw = json.dumps(
|
|
6049
|
+
snapshot,
|
|
6050
|
+
ensure_ascii=False,
|
|
6051
|
+
sort_keys=True,
|
|
6052
|
+
separators=(",", ":"),
|
|
6053
|
+
).encode("utf-8")
|
|
6054
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
6055
|
+
relative = Path("reports") / "blobs" / f"rollback-{digest}.json.gz"
|
|
6056
|
+
path = knowledge / relative
|
|
6057
|
+
if not path.exists():
|
|
6058
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
6059
|
+
path.write_bytes(gzip.compress(raw, compresslevel=9, mtime=0))
|
|
6060
|
+
return {
|
|
6061
|
+
"path": str(relative).replace("\\", "/"),
|
|
6062
|
+
"sha256": digest,
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
|
|
6066
|
+
def load_rollback_snapshot(
|
|
6067
|
+
knowledge: Path,
|
|
6068
|
+
pointer: dict[str, Any],
|
|
6069
|
+
) -> dict[str, Any] | None:
|
|
6070
|
+
relative = str(pointer.get("path") or "")
|
|
6071
|
+
expected = str(pointer.get("sha256") or "")
|
|
6072
|
+
path = (knowledge / relative).resolve()
|
|
6073
|
+
reports_root = (knowledge / "reports").resolve()
|
|
6074
|
+
if not _path_is_within(path, reports_root) or not path.is_file():
|
|
6075
|
+
return None
|
|
6076
|
+
try:
|
|
6077
|
+
raw = gzip.decompress(path.read_bytes())
|
|
6078
|
+
if hashlib.sha256(raw).hexdigest() != expected:
|
|
6079
|
+
return None
|
|
6080
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
6081
|
+
except (OSError, gzip.BadGzipFile, UnicodeDecodeError, json.JSONDecodeError):
|
|
6082
|
+
return None
|
|
6083
|
+
return payload if isinstance(payload, dict) else None
|
|
6084
|
+
|
|
6085
|
+
|
|
5421
6086
|
def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dict[str, Any]:
|
|
5422
6087
|
project = project.resolve()
|
|
5423
6088
|
knowledge = project / ".harness" / "knowledge"
|
|
@@ -5456,6 +6121,8 @@ def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dic
|
|
|
5456
6121
|
continue
|
|
5457
6122
|
try:
|
|
5458
6123
|
record = apply_judge_decision(knowledge, found, source_path, item, by_id, path_by_id)
|
|
6124
|
+
before = record.pop("before")
|
|
6125
|
+
record["beforeSnapshot"] = store_rollback_snapshot(knowledge, before)
|
|
5459
6126
|
applied.append(record)
|
|
5460
6127
|
except ValueError as exc:
|
|
5461
6128
|
errors.append({"id": entry_id, "error": str(exc)})
|
|
@@ -5470,14 +6137,18 @@ def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dic
|
|
|
5470
6137
|
"applied": applied,
|
|
5471
6138
|
"errors": errors,
|
|
5472
6139
|
}
|
|
5473
|
-
judgement_path =
|
|
5474
|
-
|
|
6140
|
+
judgement_path, judgement_sha256 = write_content_addressed_json_report(
|
|
6141
|
+
knowledge,
|
|
6142
|
+
"judgements",
|
|
6143
|
+
judgement,
|
|
6144
|
+
)
|
|
5475
6145
|
decisions_ledger = persist_judge_decisions(knowledge, applied)
|
|
5476
6146
|
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
5477
6147
|
return {
|
|
5478
6148
|
"project": str(project),
|
|
5479
6149
|
"generatedAt": judgement["generatedAt"],
|
|
5480
6150
|
"judgement": str(judgement_path),
|
|
6151
|
+
"judgementSha256": judgement_sha256,
|
|
5481
6152
|
"decisionsLedger": str(decisions_ledger),
|
|
5482
6153
|
"applied": len(applied),
|
|
5483
6154
|
"errors": errors,
|
|
@@ -5533,6 +6204,13 @@ def rollback_judgement(project: Path, judgement_path: Path) -> dict[str, Any]:
|
|
|
5533
6204
|
if not isinstance(item, dict):
|
|
5534
6205
|
continue
|
|
5535
6206
|
before = item.get("before")
|
|
6207
|
+
if not isinstance(before, dict):
|
|
6208
|
+
pointer = item.get("beforeSnapshot")
|
|
6209
|
+
before = (
|
|
6210
|
+
load_rollback_snapshot(knowledge, pointer)
|
|
6211
|
+
if isinstance(pointer, dict)
|
|
6212
|
+
else None
|
|
6213
|
+
)
|
|
5536
6214
|
if not isinstance(before, dict) or not before.get("id"):
|
|
5537
6215
|
continue
|
|
5538
6216
|
entry_id = str(before["id"])
|
|
@@ -5726,7 +6404,217 @@ def drain_maintenance_outbox(
|
|
|
5726
6404
|
}
|
|
5727
6405
|
|
|
5728
6406
|
|
|
6407
|
+
def repair_legacy_publication_gates(
|
|
6408
|
+
project: Path,
|
|
6409
|
+
*,
|
|
6410
|
+
apply: bool = True,
|
|
6411
|
+
) -> dict[str, Any]:
|
|
6412
|
+
project = project.resolve()
|
|
6413
|
+
knowledge = project / ".harness" / "knowledge"
|
|
6414
|
+
path = knowledge / "publication-attestations.json"
|
|
6415
|
+
records: dict[str, Any] = {}
|
|
6416
|
+
if path.exists():
|
|
6417
|
+
try:
|
|
6418
|
+
current = read_json(path)
|
|
6419
|
+
if isinstance(current, dict) and isinstance(current.get("records"), dict):
|
|
6420
|
+
records = dict(current["records"])
|
|
6421
|
+
except (OSError, json.JSONDecodeError):
|
|
6422
|
+
pass
|
|
6423
|
+
verified: list[str] = []
|
|
6424
|
+
needs_confirmation: list[str] = []
|
|
6425
|
+
for summary_path in discover_archive_summary_paths(project):
|
|
6426
|
+
try:
|
|
6427
|
+
summary = read_json(summary_path)
|
|
6428
|
+
except (OSError, json.JSONDecodeError):
|
|
6429
|
+
continue
|
|
6430
|
+
if not isinstance(summary, dict):
|
|
6431
|
+
continue
|
|
6432
|
+
consistency = (summary.get("reportPipeline") or {}).get("sourceConsistency")
|
|
6433
|
+
if consistency is not None:
|
|
6434
|
+
continue
|
|
6435
|
+
resolution = resolve_archive_summary(summary_path)
|
|
6436
|
+
archive_dir = archive_dir_from_summary(summary_path)
|
|
6437
|
+
archive_rel = rel_to_project(project, archive_dir)
|
|
6438
|
+
summary_hash = str(resolution.get("summarySha256") or "")
|
|
6439
|
+
source_commit = str(
|
|
6440
|
+
summary.get("finalCommit") or summary.get("final_commit") or ""
|
|
6441
|
+
)
|
|
6442
|
+
source_commit_verified = bool(
|
|
6443
|
+
source_commit
|
|
6444
|
+
and is_git_repo(project)
|
|
6445
|
+
and git_commit_exists(project, source_commit)
|
|
6446
|
+
)
|
|
6447
|
+
authority_verified = bool(resolution.get("allowed") and summary_hash)
|
|
6448
|
+
if not (authority_verified and source_commit_verified):
|
|
6449
|
+
needs_confirmation.append(archive_rel)
|
|
6450
|
+
continue
|
|
6451
|
+
record = {
|
|
6452
|
+
"status": "verified",
|
|
6453
|
+
"summarySha256": summary_hash,
|
|
6454
|
+
"sourceCommit": source_commit,
|
|
6455
|
+
"authorityStatus": resolution.get("status"),
|
|
6456
|
+
"verifiedAt": now_iso(),
|
|
6457
|
+
"method": "authoritative-pointer-and-source-commit",
|
|
6458
|
+
}
|
|
6459
|
+
record["attestationSha256"] = sha256_text(
|
|
6460
|
+
json.dumps(
|
|
6461
|
+
record,
|
|
6462
|
+
ensure_ascii=False,
|
|
6463
|
+
sort_keys=True,
|
|
6464
|
+
separators=(",", ":"),
|
|
6465
|
+
)
|
|
6466
|
+
)
|
|
6467
|
+
records[archive_rel] = record
|
|
6468
|
+
verified.append(archive_rel)
|
|
6469
|
+
if apply and verified:
|
|
6470
|
+
write_json(
|
|
6471
|
+
path,
|
|
6472
|
+
{
|
|
6473
|
+
"schemaVersion": 1,
|
|
6474
|
+
"updatedAt": now_iso(),
|
|
6475
|
+
"records": dict(sorted(records.items())),
|
|
6476
|
+
},
|
|
6477
|
+
)
|
|
6478
|
+
return {
|
|
6479
|
+
"verified": len(verified),
|
|
6480
|
+
"verifiedArchives": verified,
|
|
6481
|
+
"needsConfirmation": len(needs_confirmation),
|
|
6482
|
+
"needsConfirmationArchives": needs_confirmation,
|
|
6483
|
+
"applied": apply and bool(verified),
|
|
6484
|
+
"path": str(path),
|
|
6485
|
+
}
|
|
6486
|
+
|
|
6487
|
+
|
|
6488
|
+
def prune_knowledge_reports(knowledge: Path, keep: int = 10) -> dict[str, int]:
|
|
6489
|
+
reports = knowledge / "reports"
|
|
6490
|
+
pinned: set[Path] = set()
|
|
6491
|
+
latest_path = reports / "latest.json"
|
|
6492
|
+
if latest_path.exists():
|
|
6493
|
+
try:
|
|
6494
|
+
latest = read_json(latest_path)
|
|
6495
|
+
latest_reports = (
|
|
6496
|
+
latest.get("reports")
|
|
6497
|
+
if isinstance(latest, dict)
|
|
6498
|
+
else None
|
|
6499
|
+
)
|
|
6500
|
+
if isinstance(latest_reports, dict):
|
|
6501
|
+
for pointer in latest_reports.values():
|
|
6502
|
+
if not isinstance(pointer, dict):
|
|
6503
|
+
continue
|
|
6504
|
+
relative = str(pointer.get("path") or "")
|
|
6505
|
+
candidate = (knowledge / relative).resolve()
|
|
6506
|
+
if _path_is_within(candidate, reports.resolve()):
|
|
6507
|
+
pinned.add(candidate)
|
|
6508
|
+
except (OSError, json.JSONDecodeError):
|
|
6509
|
+
pass
|
|
6510
|
+
removed: dict[str, int] = {}
|
|
6511
|
+
for prefix in (
|
|
6512
|
+
"audit-report-",
|
|
6513
|
+
"ingest-report-",
|
|
6514
|
+
"validator-suggestions-",
|
|
6515
|
+
"verification-report-",
|
|
6516
|
+
"judge-export-",
|
|
6517
|
+
"judgements-",
|
|
6518
|
+
):
|
|
6519
|
+
paths = sorted(
|
|
6520
|
+
reports.glob(f"{prefix}*"),
|
|
6521
|
+
key=lambda item: item.stat().st_mtime_ns,
|
|
6522
|
+
reverse=True,
|
|
6523
|
+
)
|
|
6524
|
+
removed[prefix.rstrip("-")] = 0
|
|
6525
|
+
retained = set(paths[: max(0, keep)]) | pinned
|
|
6526
|
+
for path in paths:
|
|
6527
|
+
if path.resolve() in {item.resolve() for item in retained}:
|
|
6528
|
+
continue
|
|
6529
|
+
path.unlink()
|
|
6530
|
+
removed[prefix.rstrip("-")] += 1
|
|
6531
|
+
|
|
6532
|
+
referenced_blobs: set[Path] = set()
|
|
6533
|
+
for path in reports.glob("judgements-*.json"):
|
|
6534
|
+
try:
|
|
6535
|
+
judgement = read_json(path)
|
|
6536
|
+
except (OSError, json.JSONDecodeError):
|
|
6537
|
+
continue
|
|
6538
|
+
if not isinstance(judgement, dict):
|
|
6539
|
+
continue
|
|
6540
|
+
for item in judgement.get("applied") or []:
|
|
6541
|
+
if not isinstance(item, dict):
|
|
6542
|
+
continue
|
|
6543
|
+
pointer = item.get("beforeSnapshot")
|
|
6544
|
+
if not isinstance(pointer, dict):
|
|
6545
|
+
continue
|
|
6546
|
+
relative = str(pointer.get("path") or "")
|
|
6547
|
+
candidate = (knowledge / relative).resolve()
|
|
6548
|
+
if _path_is_within(candidate, reports.resolve()):
|
|
6549
|
+
referenced_blobs.add(candidate)
|
|
6550
|
+
removed["rollback-blobs"] = 0
|
|
6551
|
+
for path in (reports / "blobs").glob("rollback-*.json.gz"):
|
|
6552
|
+
if path.resolve() in referenced_blobs:
|
|
6553
|
+
continue
|
|
6554
|
+
path.unlink()
|
|
6555
|
+
removed["rollback-blobs"] += 1
|
|
6556
|
+
return removed
|
|
6557
|
+
|
|
6558
|
+
|
|
6559
|
+
def repair_knowledge(
|
|
6560
|
+
project: Path,
|
|
6561
|
+
action: str = "all",
|
|
6562
|
+
*,
|
|
6563
|
+
apply: bool = True,
|
|
6564
|
+
) -> dict[str, Any]:
|
|
6565
|
+
project = project.resolve()
|
|
6566
|
+
if action not in {"all", "legacy-publication-gates"}:
|
|
6567
|
+
raise ValueError(f"unsupported repair action: {action}")
|
|
6568
|
+
legacy = repair_legacy_publication_gates(project, apply=apply)
|
|
6569
|
+
index = (
|
|
6570
|
+
build_index(project, incremental=False)
|
|
6571
|
+
if action == "all" and apply
|
|
6572
|
+
else None
|
|
6573
|
+
)
|
|
6574
|
+
retention = (
|
|
6575
|
+
prune_knowledge_reports(project / ".harness" / "knowledge")
|
|
6576
|
+
if action == "all" and apply
|
|
6577
|
+
else {}
|
|
6578
|
+
)
|
|
6579
|
+
status = sync_status(project, check=True)
|
|
6580
|
+
return {
|
|
6581
|
+
"command": "repair",
|
|
6582
|
+
"project": str(project),
|
|
6583
|
+
"action": action,
|
|
6584
|
+
"applied": apply,
|
|
6585
|
+
"legacyPublicationGates": legacy,
|
|
6586
|
+
"dedupe": {
|
|
6587
|
+
"exactDuplicatesRemoved": (
|
|
6588
|
+
index.get("ingestMode", {}).get("exactDuplicatesRemoved", 0)
|
|
6589
|
+
if isinstance(index, dict)
|
|
6590
|
+
else 0
|
|
6591
|
+
),
|
|
6592
|
+
"normalizedDuplicatesRemoved": (
|
|
6593
|
+
index.get("ingestMode", {}).get("normalizedDuplicatesRemoved", 0)
|
|
6594
|
+
if isinstance(index, dict)
|
|
6595
|
+
else 0
|
|
6596
|
+
),
|
|
6597
|
+
"nearDuplicatesMerged": (
|
|
6598
|
+
index.get("ingestMode", {}).get("nearDuplicatesMerged", 0)
|
|
6599
|
+
if isinstance(index, dict)
|
|
6600
|
+
else 0
|
|
6601
|
+
),
|
|
6602
|
+
},
|
|
6603
|
+
"reportRetentionRemoved": retention,
|
|
6604
|
+
"freshness": status["freshness"],
|
|
6605
|
+
"health": status["health"],
|
|
6606
|
+
}
|
|
6607
|
+
|
|
6608
|
+
|
|
5729
6609
|
def main(argv: list[str] | None = None) -> int:
|
|
6610
|
+
effective_argv = list(sys.argv[1:] if argv is None else argv)
|
|
6611
|
+
if not effective_argv:
|
|
6612
|
+
effective_argv = ["auto"]
|
|
6613
|
+
if effective_argv[0] == "admin":
|
|
6614
|
+
effective_argv = effective_argv[1:]
|
|
6615
|
+
if not effective_argv:
|
|
6616
|
+
print("admin requires an advanced command", file=sys.stderr)
|
|
6617
|
+
return 2
|
|
5730
6618
|
parser = argparse.ArgumentParser(description="Build and query Harness knowledge indexes.")
|
|
5731
6619
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
5732
6620
|
|
|
@@ -5747,6 +6635,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
5747
6635
|
sync = sub.add_parser("sync", help="Check whether .harness/knowledge is current")
|
|
5748
6636
|
sync.add_argument("--project", default=".", help="Project root containing .harness/archive")
|
|
5749
6637
|
sync.add_argument("--update", action="store_true", help="Rebuild the index when it is out of date")
|
|
6638
|
+
sync.add_argument(
|
|
6639
|
+
"--check",
|
|
6640
|
+
action="store_true",
|
|
6641
|
+
help="Perform a complete read-only freshness and health check",
|
|
6642
|
+
)
|
|
5750
6643
|
sync.add_argument(
|
|
5751
6644
|
"--no-incremental",
|
|
5752
6645
|
action="store_true",
|
|
@@ -5782,6 +6675,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
5782
6675
|
)
|
|
5783
6676
|
auto.add_argument("--no-incremental", action="store_true", help="Refresh without reusing the archive entry cache")
|
|
5784
6677
|
|
|
6678
|
+
status = sub.add_parser("status", help="Read-only knowledge freshness and health")
|
|
6679
|
+
status.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
6680
|
+
|
|
6681
|
+
repair = sub.add_parser("repair", help="Repair legacy gates, duplicates, conflicts, and report retention")
|
|
6682
|
+
repair.add_argument(
|
|
6683
|
+
"repair_action",
|
|
6684
|
+
nargs="?",
|
|
6685
|
+
default="all",
|
|
6686
|
+
choices=["all", "legacy-publication-gates"],
|
|
6687
|
+
)
|
|
6688
|
+
repair.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
6689
|
+
repair.add_argument("--no-apply", action="store_true", help="Diagnose without writing repairs")
|
|
6690
|
+
|
|
5785
6691
|
audit = sub.add_parser("audit", help="Generate review lists for candidate, stale, and superseded entries")
|
|
5786
6692
|
audit.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
5787
6693
|
audit.add_argument("--limit", type=int, default=10)
|
|
@@ -5871,7 +6777,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
5871
6777
|
maintain.add_argument("--limit", type=int, default=50, help="Maximum items processed by --drain")
|
|
5872
6778
|
maintain.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
5873
6779
|
|
|
5874
|
-
args = parser.parse_args(
|
|
6780
|
+
args = parser.parse_args(effective_argv)
|
|
5875
6781
|
if args.command == "ingest":
|
|
5876
6782
|
index = build_index(
|
|
5877
6783
|
Path(args.project),
|
|
@@ -5886,9 +6792,26 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
5886
6792
|
args.update,
|
|
5887
6793
|
incremental=not args.no_incremental,
|
|
5888
6794
|
progress=ProgressReporter(args.progress),
|
|
6795
|
+
check=args.check,
|
|
5889
6796
|
)
|
|
5890
6797
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
5891
6798
|
return 0
|
|
6799
|
+
if args.command == "status":
|
|
6800
|
+
result = sync_status(Path(args.project), check=True)
|
|
6801
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
6802
|
+
return 0
|
|
6803
|
+
if args.command == "repair":
|
|
6804
|
+
try:
|
|
6805
|
+
result = repair_knowledge(
|
|
6806
|
+
Path(args.project),
|
|
6807
|
+
args.repair_action,
|
|
6808
|
+
apply=not args.no_apply,
|
|
6809
|
+
)
|
|
6810
|
+
except ValueError as exc:
|
|
6811
|
+
print(str(exc), file=sys.stderr)
|
|
6812
|
+
return 1
|
|
6813
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
6814
|
+
return 0
|
|
5892
6815
|
if args.command == "auto":
|
|
5893
6816
|
result = auto_knowledge(
|
|
5894
6817
|
Path(args.project),
|