hunter-harness 0.2.0 → 0.2.1
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/dist/bin.js +659 -230
- package/package.json +9 -7
- package/resources/harness/bundles/general/claude-code/.harness-build.json +0 -6
- package/resources/harness/bundles/general/claude-code/CONTEXT.md +0 -65
- package/resources/harness/bundles/general/claude-code/README.md +0 -478
- package/resources/harness/bundles/general/claude-code/agents/harness-evaluator.md +0 -96
- package/resources/harness/bundles/general/claude-code/agents/harness-explorer.md +0 -72
- package/resources/harness/bundles/general/claude-code/agents/harness-reviewer.md +0 -69
- package/resources/harness/bundles/general/claude-code/harness-archive/SKILL.md +0 -215
- package/resources/harness/bundles/general/claude-code/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/general/claude-code/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/general/claude-code/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +0 -112
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/general/claude-code/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +0 -246
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +0 -164
- package/resources/harness/bundles/general/claude-code/harness-plan/SKILL.md +0 -127
- package/resources/harness/bundles/general/claude-code/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/general/claude-code/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/general/claude-code/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/general/claude-code/harness-review/SKILL.md +0 -160
- package/resources/harness/bundles/general/claude-code/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/general/claude-code/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/general/claude-code/harness-review/reference.md +0 -86
- package/resources/harness/bundles/general/claude-code/harness-run/SKILL.md +0 -132
- package/resources/harness/bundles/general/claude-code/harness-run/checklist.md +0 -259
- package/resources/harness/bundles/general/claude-code/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/general/claude-code/harness-run/reference.md +0 -834
- package/resources/harness/bundles/general/claude-code/harness-submit/SKILL.md +0 -159
- package/resources/harness/bundles/general/claude-code/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/general/claude-code/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/general/claude-code/harness-sync/SKILL.md +0 -82
- package/resources/harness/bundles/general/claude-code/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/general/claude-code/harness-test/SKILL.md +0 -180
- package/resources/harness/bundles/general/claude-code/harness-test/checklist.md +0 -319
- package/resources/harness/bundles/general/claude-code/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/general/claude-code/harness-test/reference.md +0 -791
- package/resources/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/general/claude-code/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/general/claude-code/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/general/claude-code/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/general/claude-code/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/general/claude-code/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/general/claude-code/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/general/claude-code/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/general/claude-code/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/general/claude-code/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/general/claude-code/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/general/codebuddy/.harness-build.json +0 -6
- package/resources/harness/bundles/general/codebuddy/CONTEXT.md +0 -65
- package/resources/harness/bundles/general/codebuddy/README.md +0 -478
- package/resources/harness/bundles/general/codebuddy/agents/harness-evaluator.md +0 -94
- package/resources/harness/bundles/general/codebuddy/agents/harness-explorer.md +0 -53
- package/resources/harness/bundles/general/codebuddy/agents/harness-reviewer.md +0 -52
- package/resources/harness/bundles/general/codebuddy/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/general/codebuddy/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/general/codebuddy/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/general/codebuddy/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/general/codebuddy/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/general/codebuddy/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/general/codebuddy/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/general/codebuddy/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/general/codebuddy/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/general/codebuddy/harness-review/SKILL.md +0 -142
- package/resources/harness/bundles/general/codebuddy/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/general/codebuddy/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/general/codebuddy/harness-review/reference.md +0 -86
- package/resources/harness/bundles/general/codebuddy/harness-run/SKILL.md +0 -113
- package/resources/harness/bundles/general/codebuddy/harness-run/checklist.md +0 -259
- package/resources/harness/bundles/general/codebuddy/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/general/codebuddy/harness-run/reference.md +0 -834
- package/resources/harness/bundles/general/codebuddy/harness-submit/SKILL.md +0 -142
- package/resources/harness/bundles/general/codebuddy/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/general/codebuddy/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/general/codebuddy/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/general/codebuddy/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/general/codebuddy/harness-test/SKILL.md +0 -160
- package/resources/harness/bundles/general/codebuddy/harness-test/checklist.md +0 -319
- package/resources/harness/bundles/general/codebuddy/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/general/codebuddy/harness-test/reference.md +0 -791
- package/resources/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/general/codebuddy/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/general/codebuddy/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/general/codebuddy/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/general/codebuddy/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/general/codebuddy/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/general/codebuddy/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/general/codebuddy/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/general/codebuddy/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/general/codex/.harness-build.json +0 -6
- package/resources/harness/bundles/general/codex/CONTEXT.md +0 -65
- package/resources/harness/bundles/general/codex/README.md +0 -478
- package/resources/harness/bundles/general/codex/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/general/codex/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/general/codex/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/general/codex/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/general/codex/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/general/codex/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/general/codex/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/general/codex/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/general/codex/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/general/codex/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/general/codex/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/general/codex/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/general/codex/harness-review/SKILL.md +0 -137
- package/resources/harness/bundles/general/codex/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/general/codex/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/general/codex/harness-review/reference.md +0 -86
- package/resources/harness/bundles/general/codex/harness-run/SKILL.md +0 -113
- package/resources/harness/bundles/general/codex/harness-run/checklist.md +0 -259
- package/resources/harness/bundles/general/codex/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/general/codex/harness-run/reference.md +0 -834
- package/resources/harness/bundles/general/codex/harness-submit/SKILL.md +0 -142
- package/resources/harness/bundles/general/codex/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/general/codex/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/general/codex/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/general/codex/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/general/codex/harness-test/SKILL.md +0 -160
- package/resources/harness/bundles/general/codex/harness-test/checklist.md +0 -319
- package/resources/harness/bundles/general/codex/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/general/codex/harness-test/reference.md +0 -791
- package/resources/harness/bundles/general/codex/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/general/codex/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/general/codex/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/general/codex/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/general/codex/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/general/codex/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/general/codex/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/general/codex/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/general/codex/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/general/codex/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/general/codex/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/general/codex/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/general/cursor/.harness-build.json +0 -6
- package/resources/harness/bundles/general/cursor/CONTEXT.md +0 -65
- package/resources/harness/bundles/general/cursor/README.md +0 -478
- package/resources/harness/bundles/general/cursor/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/general/cursor/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/general/cursor/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/general/cursor/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/general/cursor/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/general/cursor/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/general/cursor/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/general/cursor/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/general/cursor/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/general/cursor/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/general/cursor/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/general/cursor/harness-review/SKILL.md +0 -137
- package/resources/harness/bundles/general/cursor/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/general/cursor/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/general/cursor/harness-review/reference.md +0 -86
- package/resources/harness/bundles/general/cursor/harness-run/SKILL.md +0 -113
- package/resources/harness/bundles/general/cursor/harness-run/checklist.md +0 -259
- package/resources/harness/bundles/general/cursor/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/general/cursor/harness-run/reference.md +0 -834
- package/resources/harness/bundles/general/cursor/harness-submit/SKILL.md +0 -142
- package/resources/harness/bundles/general/cursor/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/general/cursor/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/general/cursor/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/general/cursor/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/general/cursor/harness-test/SKILL.md +0 -160
- package/resources/harness/bundles/general/cursor/harness-test/checklist.md +0 -319
- package/resources/harness/bundles/general/cursor/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/general/cursor/harness-test/reference.md +0 -791
- package/resources/harness/bundles/general/cursor/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/general/cursor/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/general/cursor/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/general/cursor/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/general/cursor/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/general/cursor/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/general/cursor/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/general/cursor/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/general/cursor/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/general/cursor/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/general/cursor/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/general/cursor/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/java/claude-code/.harness-build.json +0 -6
- package/resources/harness/bundles/java/claude-code/CONTEXT.md +0 -65
- package/resources/harness/bundles/java/claude-code/README.md +0 -478
- package/resources/harness/bundles/java/claude-code/agents/harness-evaluator.md +0 -96
- package/resources/harness/bundles/java/claude-code/agents/harness-explorer.md +0 -72
- package/resources/harness/bundles/java/claude-code/agents/harness-reviewer.md +0 -69
- package/resources/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +0 -86
- package/resources/harness/bundles/java/claude-code/harness-apidoc/checklist.md +0 -142
- package/resources/harness/bundles/java/claude-code/harness-apidoc/reference.md +0 -233
- package/resources/harness/bundles/java/claude-code/harness-archive/SKILL.md +0 -215
- package/resources/harness/bundles/java/claude-code/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/java/claude-code/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/java/claude-code/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +0 -112
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/java/claude-code/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +0 -246
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +0 -164
- package/resources/harness/bundles/java/claude-code/harness-package/SKILL.md +0 -87
- package/resources/harness/bundles/java/claude-code/harness-package/checklist.md +0 -322
- package/resources/harness/bundles/java/claude-code/harness-package/reference.md +0 -210
- package/resources/harness/bundles/java/claude-code/harness-plan/SKILL.md +0 -127
- package/resources/harness/bundles/java/claude-code/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/java/claude-code/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/java/claude-code/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/java/claude-code/harness-review/SKILL.md +0 -160
- package/resources/harness/bundles/java/claude-code/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/java/claude-code/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/java/claude-code/harness-review/reference.md +0 -86
- package/resources/harness/bundles/java/claude-code/harness-run/SKILL.md +0 -148
- package/resources/harness/bundles/java/claude-code/harness-run/checklist.md +0 -250
- package/resources/harness/bundles/java/claude-code/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/java/claude-code/harness-run/reference.md +0 -814
- package/resources/harness/bundles/java/claude-code/harness-submit/SKILL.md +0 -166
- package/resources/harness/bundles/java/claude-code/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/java/claude-code/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/java/claude-code/harness-sync/SKILL.md +0 -82
- package/resources/harness/bundles/java/claude-code/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/java/claude-code/harness-test/SKILL.md +0 -192
- package/resources/harness/bundles/java/claude-code/harness-test/checklist.md +0 -317
- package/resources/harness/bundles/java/claude-code/harness-test/pitfalls-java.md +0 -16
- package/resources/harness/bundles/java/claude-code/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/java/claude-code/harness-test/reference.md +0 -788
- package/resources/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/java/claude-code/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/java/claude-code/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/java/claude-code/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/java/claude-code/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/java/claude-code/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/java/claude-code/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/java/claude-code/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/java/claude-code/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/java/claude-code/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/java/claude-code/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/java/codebuddy/.harness-build.json +0 -6
- package/resources/harness/bundles/java/codebuddy/CONTEXT.md +0 -65
- package/resources/harness/bundles/java/codebuddy/README.md +0 -478
- package/resources/harness/bundles/java/codebuddy/agents/harness-evaluator.md +0 -94
- package/resources/harness/bundles/java/codebuddy/agents/harness-explorer.md +0 -53
- package/resources/harness/bundles/java/codebuddy/agents/harness-reviewer.md +0 -52
- package/resources/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +0 -68
- package/resources/harness/bundles/java/codebuddy/harness-apidoc/checklist.md +0 -142
- package/resources/harness/bundles/java/codebuddy/harness-apidoc/reference.md +0 -233
- package/resources/harness/bundles/java/codebuddy/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/java/codebuddy/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/java/codebuddy/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/java/codebuddy/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/java/codebuddy/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/java/codebuddy/harness-package/SKILL.md +0 -69
- package/resources/harness/bundles/java/codebuddy/harness-package/checklist.md +0 -322
- package/resources/harness/bundles/java/codebuddy/harness-package/reference.md +0 -210
- package/resources/harness/bundles/java/codebuddy/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/java/codebuddy/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/java/codebuddy/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/java/codebuddy/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/java/codebuddy/harness-review/SKILL.md +0 -142
- package/resources/harness/bundles/java/codebuddy/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/java/codebuddy/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/java/codebuddy/harness-review/reference.md +0 -86
- package/resources/harness/bundles/java/codebuddy/harness-run/SKILL.md +0 -129
- package/resources/harness/bundles/java/codebuddy/harness-run/checklist.md +0 -250
- package/resources/harness/bundles/java/codebuddy/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/java/codebuddy/harness-run/reference.md +0 -814
- package/resources/harness/bundles/java/codebuddy/harness-submit/SKILL.md +0 -149
- package/resources/harness/bundles/java/codebuddy/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/java/codebuddy/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/java/codebuddy/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/java/codebuddy/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/java/codebuddy/harness-test/SKILL.md +0 -172
- package/resources/harness/bundles/java/codebuddy/harness-test/checklist.md +0 -317
- package/resources/harness/bundles/java/codebuddy/harness-test/pitfalls-java.md +0 -16
- package/resources/harness/bundles/java/codebuddy/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/java/codebuddy/harness-test/reference.md +0 -788
- package/resources/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/java/codebuddy/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/java/codebuddy/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/java/codebuddy/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/java/codebuddy/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/java/codebuddy/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/java/codebuddy/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/java/codebuddy/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/java/codebuddy/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/java/codex/.harness-build.json +0 -6
- package/resources/harness/bundles/java/codex/CONTEXT.md +0 -65
- package/resources/harness/bundles/java/codex/README.md +0 -478
- package/resources/harness/bundles/java/codex/harness-apidoc/SKILL.md +0 -68
- package/resources/harness/bundles/java/codex/harness-apidoc/checklist.md +0 -142
- package/resources/harness/bundles/java/codex/harness-apidoc/reference.md +0 -233
- package/resources/harness/bundles/java/codex/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/java/codex/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/java/codex/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/java/codex/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/java/codex/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/java/codex/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/java/codex/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/java/codex/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/java/codex/harness-package/SKILL.md +0 -69
- package/resources/harness/bundles/java/codex/harness-package/checklist.md +0 -322
- package/resources/harness/bundles/java/codex/harness-package/reference.md +0 -210
- package/resources/harness/bundles/java/codex/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/java/codex/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/java/codex/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/java/codex/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/java/codex/harness-review/SKILL.md +0 -137
- package/resources/harness/bundles/java/codex/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/java/codex/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/java/codex/harness-review/reference.md +0 -86
- package/resources/harness/bundles/java/codex/harness-run/SKILL.md +0 -129
- package/resources/harness/bundles/java/codex/harness-run/checklist.md +0 -250
- package/resources/harness/bundles/java/codex/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/java/codex/harness-run/reference.md +0 -814
- package/resources/harness/bundles/java/codex/harness-submit/SKILL.md +0 -149
- package/resources/harness/bundles/java/codex/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/java/codex/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/java/codex/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/java/codex/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/java/codex/harness-test/SKILL.md +0 -172
- package/resources/harness/bundles/java/codex/harness-test/checklist.md +0 -317
- package/resources/harness/bundles/java/codex/harness-test/pitfalls-java.md +0 -16
- package/resources/harness/bundles/java/codex/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/java/codex/harness-test/reference.md +0 -788
- package/resources/harness/bundles/java/codex/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/java/codex/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/java/codex/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/java/codex/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/java/codex/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/java/codex/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/java/codex/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/java/codex/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/java/codex/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/java/codex/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/java/codex/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/java/codex/scripts/harness_service.py +0 -1331
- package/resources/harness/bundles/java/cursor/.harness-build.json +0 -6
- package/resources/harness/bundles/java/cursor/CONTEXT.md +0 -65
- package/resources/harness/bundles/java/cursor/README.md +0 -478
- package/resources/harness/bundles/java/cursor/harness-apidoc/SKILL.md +0 -68
- package/resources/harness/bundles/java/cursor/harness-apidoc/checklist.md +0 -142
- package/resources/harness/bundles/java/cursor/harness-apidoc/reference.md +0 -233
- package/resources/harness/bundles/java/cursor/harness-archive/SKILL.md +0 -198
- package/resources/harness/bundles/java/cursor/harness-archive/checklist.md +0 -52
- package/resources/harness/bundles/java/cursor/harness-archive/reference.md +0 -103
- package/resources/harness/bundles/java/cursor/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +0 -95
- package/resources/harness/bundles/java/cursor/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/bundles/java/cursor/harness-codebase-map/reference.md +0 -364
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/bundles/java/cursor/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +0 -229
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +0 -147
- package/resources/harness/bundles/java/cursor/harness-package/SKILL.md +0 -69
- package/resources/harness/bundles/java/cursor/harness-package/checklist.md +0 -322
- package/resources/harness/bundles/java/cursor/harness-package/reference.md +0 -210
- package/resources/harness/bundles/java/cursor/harness-plan/SKILL.md +0 -109
- package/resources/harness/bundles/java/cursor/harness-plan/checklist.md +0 -243
- package/resources/harness/bundles/java/cursor/harness-plan/protocols.md +0 -124
- package/resources/harness/bundles/java/cursor/harness-plan/reference.md +0 -376
- package/resources/harness/bundles/java/cursor/harness-review/SKILL.md +0 -137
- package/resources/harness/bundles/java/cursor/harness-review/checklist.md +0 -124
- package/resources/harness/bundles/java/cursor/harness-review/protocols.md +0 -68
- package/resources/harness/bundles/java/cursor/harness-review/reference.md +0 -86
- package/resources/harness/bundles/java/cursor/harness-run/SKILL.md +0 -129
- package/resources/harness/bundles/java/cursor/harness-run/checklist.md +0 -250
- package/resources/harness/bundles/java/cursor/harness-run/protocols.md +0 -78
- package/resources/harness/bundles/java/cursor/harness-run/reference.md +0 -814
- package/resources/harness/bundles/java/cursor/harness-submit/SKILL.md +0 -149
- package/resources/harness/bundles/java/cursor/harness-submit/checklist.md +0 -407
- package/resources/harness/bundles/java/cursor/harness-submit/reference.md +0 -152
- package/resources/harness/bundles/java/cursor/harness-sync/SKILL.md +0 -65
- package/resources/harness/bundles/java/cursor/harness-sync/reference.md +0 -153
- package/resources/harness/bundles/java/cursor/harness-test/SKILL.md +0 -172
- package/resources/harness/bundles/java/cursor/harness-test/checklist.md +0 -317
- package/resources/harness/bundles/java/cursor/harness-test/pitfalls-java.md +0 -16
- package/resources/harness/bundles/java/cursor/harness-test/pitfalls.md +0 -260
- package/resources/harness/bundles/java/cursor/harness-test/reference.md +0 -788
- package/resources/harness/bundles/java/cursor/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/bundles/java/cursor/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/bundles/java/cursor/protocols/ledger-protocol.md +0 -270
- package/resources/harness/bundles/java/cursor/protocols/powershell-protocol.md +0 -181
- package/resources/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/bundles/java/cursor/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/bundles/java/cursor/protocols/submit-protocol.md +0 -78
- package/resources/harness/bundles/java/cursor/scripts/harness_archive.py +0 -2319
- package/resources/harness/bundles/java/cursor/scripts/harness_deploy.py +0 -674
- package/resources/harness/bundles/java/cursor/scripts/harness_events.py +0 -680
- package/resources/harness/bundles/java/cursor/scripts/harness_ledger.py +0 -671
- package/resources/harness/bundles/java/cursor/scripts/harness_preflight.py +0 -780
- package/resources/harness/bundles/java/cursor/scripts/harness_service.py +0 -1331
- package/resources/harness/manifests/general/claude-code.json +0 -277
- package/resources/harness/manifests/general/codebuddy.json +0 -277
- package/resources/harness/manifests/general/codex.json +0 -265
- package/resources/harness/manifests/general/cursor.json +0 -265
- package/resources/harness/manifests/java/claude-code.json +0 -305
- package/resources/harness/manifests/java/codebuddy.json +0 -305
- package/resources/harness/manifests/java/codex.json +0 -293
- package/resources/harness/manifests/java/cursor.json +0 -293
- package/resources/harness/migrations/0.1.1/general.json +0 -358
- package/resources/harness/migrations/0.1.1/java.json +0 -398
|
@@ -1,4082 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Build and query a local Harness knowledge index.
|
|
3
|
-
|
|
4
|
-
This script is intentionally dependency-free. It turns existing
|
|
5
|
-
.harness/archive/**/reports/final/summary-data.json files into a project-local
|
|
6
|
-
.harness/knowledge index with candidate entries, SQLite FTS5, Obsidian views,
|
|
7
|
-
and query context packs.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import datetime as dt
|
|
14
|
-
import hashlib
|
|
15
|
-
import json
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
18
|
-
import shutil
|
|
19
|
-
import sqlite3
|
|
20
|
-
import subprocess
|
|
21
|
-
import sys
|
|
22
|
-
from difflib import SequenceMatcher
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import Any
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
28
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
29
|
-
if hasattr(sys.stderr, "reconfigure"):
|
|
30
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ENTRY_TYPES = {
|
|
34
|
-
"requirement",
|
|
35
|
-
"decision",
|
|
36
|
-
"implementation",
|
|
37
|
-
"risk",
|
|
38
|
-
"test-evidence",
|
|
39
|
-
"pitfall",
|
|
40
|
-
"api-contract",
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
DEFAULT_AUTO_KNOWLEDGE_CONFIG = {
|
|
44
|
-
"autoPromote": {
|
|
45
|
-
"enabled": True,
|
|
46
|
-
"minConfidence": 0.82,
|
|
47
|
-
"allowedTypes": ["decision", "api-contract", "requirement", "pitfall"],
|
|
48
|
-
"requireValidators": False,
|
|
49
|
-
"allowStale": False,
|
|
50
|
-
"maxPerRun": 50,
|
|
51
|
-
},
|
|
52
|
-
"confidence": {
|
|
53
|
-
"ttlHalfLifeDays": 45,
|
|
54
|
-
"sourceChangePenalty": 0.25,
|
|
55
|
-
"stalePenalty": 0.35,
|
|
56
|
-
"validatorPassBonus": 0.15,
|
|
57
|
-
"validatorFailPenalty": 0.5,
|
|
58
|
-
"supersededPenalty": 0.8,
|
|
59
|
-
"conflictPenalty": 0.8,
|
|
60
|
-
},
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def now_iso() -> str:
|
|
65
|
-
return dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def timestamp() -> str:
|
|
69
|
-
return dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def read_json(path: Path) -> Any:
|
|
73
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def write_json(path: Path, data: Any) -> None:
|
|
77
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def write_text(path: Path, text: str) -> None:
|
|
82
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
-
path.write_text(text, encoding="utf-8")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def json_clone(value: Any) -> Any:
|
|
87
|
-
return json.loads(json.dumps(value, ensure_ascii=False))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def load_config(knowledge: Path) -> dict[str, Any]:
|
|
91
|
-
config_path = knowledge / "config.json"
|
|
92
|
-
if not config_path.exists():
|
|
93
|
-
return {}
|
|
94
|
-
try:
|
|
95
|
-
config = read_json(config_path)
|
|
96
|
-
except (OSError, json.JSONDecodeError):
|
|
97
|
-
return {}
|
|
98
|
-
return config if isinstance(config, dict) else {}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def ensure_auto_knowledge_config(knowledge: Path) -> dict[str, Any]:
|
|
102
|
-
config_path = knowledge / "config.json"
|
|
103
|
-
if config_path.exists():
|
|
104
|
-
config = load_config(knowledge)
|
|
105
|
-
return {
|
|
106
|
-
"path": str(config_path),
|
|
107
|
-
"created": False,
|
|
108
|
-
"autoPromoteEnabled": auto_promote_config(config)["enabled"],
|
|
109
|
-
"appliedBy": "existing",
|
|
110
|
-
"candidateAutoPromoted": None,
|
|
111
|
-
}
|
|
112
|
-
config = json_clone(DEFAULT_AUTO_KNOWLEDGE_CONFIG)
|
|
113
|
-
write_json(config_path, config)
|
|
114
|
-
return {
|
|
115
|
-
"path": str(config_path),
|
|
116
|
-
"created": True,
|
|
117
|
-
"autoPromoteEnabled": True,
|
|
118
|
-
"appliedBy": None,
|
|
119
|
-
"candidateAutoPromoted": None,
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def active_lifecycle_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
124
|
-
raw = config.get("activeLifecycle")
|
|
125
|
-
if not isinstance(raw, dict):
|
|
126
|
-
return {"autoDemote": False, "targetStatus": "stale"}
|
|
127
|
-
target_status = str(raw.get("targetStatus") or "stale")
|
|
128
|
-
if target_status not in {"candidate", "stale"}:
|
|
129
|
-
target_status = "stale"
|
|
130
|
-
return {
|
|
131
|
-
"autoDemote": bool(raw.get("autoDemote")),
|
|
132
|
-
"targetStatus": target_status,
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def knowledge_validation_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
-
raw = config.get("knowledgeValidation")
|
|
138
|
-
if not isinstance(raw, dict):
|
|
139
|
-
raw = {}
|
|
140
|
-
target_status = str(raw.get("defaultTargetStatus") or "stale")
|
|
141
|
-
if target_status not in {"candidate", "stale"}:
|
|
142
|
-
target_status = "stale"
|
|
143
|
-
return {
|
|
144
|
-
"enabled": bool(raw.get("enabled", True)),
|
|
145
|
-
"autoDemoteActive": bool(raw.get("autoDemoteActive", False)),
|
|
146
|
-
"defaultTargetStatus": target_status,
|
|
147
|
-
"allowCommandValidators": bool(raw.get("allowCommandValidators", False)),
|
|
148
|
-
"commandTimeoutSeconds": int(raw.get("commandTimeoutSeconds") or 60),
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def confidence_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
153
|
-
raw = config.get("confidence")
|
|
154
|
-
if not isinstance(raw, dict):
|
|
155
|
-
raw = {}
|
|
156
|
-
return {
|
|
157
|
-
"ttlHalfLifeDays": max(1, int(raw.get("ttlHalfLifeDays") or 45)),
|
|
158
|
-
"sourceChangePenalty": float(raw.get("sourceChangePenalty", 0.25)),
|
|
159
|
-
"stalePenalty": float(raw.get("stalePenalty", 0.35)),
|
|
160
|
-
"validatorPassBonus": float(raw.get("validatorPassBonus", 0.15)),
|
|
161
|
-
"validatorFailPenalty": float(raw.get("validatorFailPenalty", 0.5)),
|
|
162
|
-
"supersededPenalty": float(raw.get("supersededPenalty", 0.8)),
|
|
163
|
-
"conflictPenalty": float(raw.get("conflictPenalty", 0.8)),
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def auto_promote_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
168
|
-
raw = config.get("autoPromote")
|
|
169
|
-
if not isinstance(raw, dict):
|
|
170
|
-
raw = {}
|
|
171
|
-
allowed = raw.get("allowedTypes")
|
|
172
|
-
if not isinstance(allowed, list):
|
|
173
|
-
allowed = ["decision", "api-contract", "requirement", "pitfall"]
|
|
174
|
-
allowed_types = [str(item) for item in allowed if str(item) in ENTRY_TYPES]
|
|
175
|
-
if not allowed_types:
|
|
176
|
-
allowed_types = ["decision", "api-contract", "requirement", "pitfall"]
|
|
177
|
-
max_per_run = int(raw.get("maxPerRun") or 50)
|
|
178
|
-
return {
|
|
179
|
-
"enabled": bool(raw.get("enabled", False)),
|
|
180
|
-
"minConfidence": float(raw.get("minConfidence", 0.82)),
|
|
181
|
-
"allowedTypes": allowed_types,
|
|
182
|
-
"requireValidators": bool(raw.get("requireValidators", False)),
|
|
183
|
-
"allowStale": bool(raw.get("allowStale", False)),
|
|
184
|
-
"maxPerRun": max(0, max_per_run),
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def sha256_file(path: Path) -> str:
|
|
189
|
-
h = hashlib.sha256()
|
|
190
|
-
with path.open("rb") as f:
|
|
191
|
-
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
192
|
-
h.update(chunk)
|
|
193
|
-
return h.hexdigest()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def short_hash(value: str, length: int = 10) -> str:
|
|
197
|
-
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:length]
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def slugify(value: str, max_len: int = 64) -> str:
|
|
201
|
-
value = value.lower().strip()
|
|
202
|
-
value = re.sub(r"[^\w\u4e00-\u9fff]+", "-", value, flags=re.UNICODE)
|
|
203
|
-
value = re.sub(r"-+", "-", value).strip("-")
|
|
204
|
-
if not value:
|
|
205
|
-
value = "item"
|
|
206
|
-
return value[:max_len].strip("-") or "item"
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def safe_filename(value: str) -> str:
|
|
210
|
-
slug = slugify(value, 72)
|
|
211
|
-
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "-", slug)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def entry_filename(entry: dict) -> str:
|
|
215
|
-
"""生成 entry 文件名:可读前缀(截断安全)+ 完整 10 字符哈希(永不被截)。
|
|
216
|
-
|
|
217
|
-
id 形如 ``project.archive.type.hash10``;rsplit 取末段为消歧哈希,
|
|
218
|
-
拼在 safe_filename 截断之外,避免长归档名让 72 上限切掉哈希导致撞名。
|
|
219
|
-
末段不足 6 字符或 id 无 hash 段时回退为 short_hash(id, 10);
|
|
220
|
-
末段含文件系统非法字符时按 safe_filename 同款规则清理,避免非法文件名。
|
|
221
|
-
"""
|
|
222
|
-
entry_id = entry["id"]
|
|
223
|
-
parts = entry_id.rsplit(".", 1)
|
|
224
|
-
prefix = parts[0] if len(parts) == 2 else entry_id
|
|
225
|
-
digest = parts[1] if len(parts) == 2 and len(parts[1]) >= 6 else short_hash(entry_id, 10)
|
|
226
|
-
digest = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "-", digest)
|
|
227
|
-
return safe_filename(prefix) + "-" + digest + ".json"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def rel_to_project(project: Path, path: Path) -> str:
|
|
231
|
-
try:
|
|
232
|
-
return path.resolve().relative_to(project.resolve()).as_posix()
|
|
233
|
-
except ValueError:
|
|
234
|
-
return path.as_posix()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def project_id(project: Path) -> str:
|
|
238
|
-
config = project / ".harness" / "project.yaml"
|
|
239
|
-
if config.exists():
|
|
240
|
-
text = config.read_text(encoding="utf-8", errors="ignore")
|
|
241
|
-
match = re.search(r"(?m)^project[_-]?id:\s*['\"]?([^'\"\n#]+)", text)
|
|
242
|
-
if match:
|
|
243
|
-
return slugify(match.group(1).strip(), 80)
|
|
244
|
-
return slugify(project.name, 80)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def git_head(project: Path) -> str | None:
|
|
248
|
-
result = run_git(project, ["rev-parse", "HEAD"])
|
|
249
|
-
return result.stdout.strip() if result.returncode == 0 else None
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def is_git_repo(project: Path) -> bool:
|
|
253
|
-
result = run_git(project, ["rev-parse", "--is-inside-work-tree"])
|
|
254
|
-
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def git_commit_exists(project: Path, commit: str) -> bool:
|
|
258
|
-
result = run_git(project, ["cat-file", "-e", f"{commit}^{{commit}}"])
|
|
259
|
-
return result.returncode == 0
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def run_git(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
263
|
-
try:
|
|
264
|
-
return subprocess.run(
|
|
265
|
-
["git", "-C", str(project), *args],
|
|
266
|
-
text=True,
|
|
267
|
-
stdout=subprocess.PIPE,
|
|
268
|
-
stderr=subprocess.PIPE,
|
|
269
|
-
timeout=20,
|
|
270
|
-
)
|
|
271
|
-
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
272
|
-
return subprocess.CompletedProcess(args, 1, "", str(exc))
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def archive_name(summary_path: Path) -> str:
|
|
276
|
-
parts = summary_path.parts
|
|
277
|
-
try:
|
|
278
|
-
idx = parts.index("archive")
|
|
279
|
-
return parts[idx + 1]
|
|
280
|
-
except (ValueError, IndexError):
|
|
281
|
-
return summary_path.parent.name
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def archive_dir_from_summary(summary_path: Path) -> Path:
|
|
285
|
-
archive = archive_name(summary_path)
|
|
286
|
-
current = summary_path
|
|
287
|
-
for parent in summary_path.parents:
|
|
288
|
-
if parent.name == archive and parent.parent.name == "archive":
|
|
289
|
-
return parent
|
|
290
|
-
current = parent
|
|
291
|
-
return current
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def changed_file_paths(summary: dict[str, Any]) -> list[str]:
|
|
295
|
-
files = []
|
|
296
|
-
for item in summary.get("changedFiles") or []:
|
|
297
|
-
path = item.get("path") if isinstance(item, dict) else None
|
|
298
|
-
if path and path not in files:
|
|
299
|
-
files.append(str(path))
|
|
300
|
-
return files
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def archive_summary_records(project: Path, summary_paths: list[Path]) -> list[dict[str, Any]]:
|
|
304
|
-
records = []
|
|
305
|
-
for summary_path in summary_paths:
|
|
306
|
-
stat = summary_path.stat()
|
|
307
|
-
records.append(
|
|
308
|
-
{
|
|
309
|
-
"archive": rel_to_project(project, archive_dir_from_summary(summary_path)),
|
|
310
|
-
"summaryData": rel_to_project(project, summary_path),
|
|
311
|
-
"summarySha256": sha256_file(summary_path),
|
|
312
|
-
"mtime": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat(timespec="seconds"),
|
|
313
|
-
}
|
|
314
|
-
)
|
|
315
|
-
return records
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def make_entry(
|
|
319
|
-
*,
|
|
320
|
-
project: Path,
|
|
321
|
-
project_name: str,
|
|
322
|
-
summary_path: Path,
|
|
323
|
-
summary_hash: str,
|
|
324
|
-
summary: dict[str, Any],
|
|
325
|
-
entry_type: str,
|
|
326
|
-
title: str,
|
|
327
|
-
body: str,
|
|
328
|
-
source_files: list[str],
|
|
329
|
-
keywords: list[str],
|
|
330
|
-
confidence: str = "medium",
|
|
331
|
-
) -> dict[str, Any]:
|
|
332
|
-
if entry_type not in ENTRY_TYPES:
|
|
333
|
-
raise ValueError(f"unsupported entry type: {entry_type}")
|
|
334
|
-
|
|
335
|
-
archive = archive_name(summary_path)
|
|
336
|
-
final_commit = str(summary.get("finalCommit") or summary.get("final_commit") or "")
|
|
337
|
-
base_commit = str(summary.get("baseCommit") or summary.get("base_commit") or "")
|
|
338
|
-
status = "candidate"
|
|
339
|
-
stale_reasons: list[str] = []
|
|
340
|
-
|
|
341
|
-
if final_commit and is_git_repo(project):
|
|
342
|
-
if not git_commit_exists(project, final_commit):
|
|
343
|
-
stale_reasons.append(
|
|
344
|
-
"source commit missing from local git history: " + final_commit[:12]
|
|
345
|
-
)
|
|
346
|
-
elif source_files:
|
|
347
|
-
diff = run_git(project, ["diff", "--name-only", f"{final_commit}..HEAD", "--", *source_files])
|
|
348
|
-
if diff.returncode == 0:
|
|
349
|
-
changed = [line.strip() for line in diff.stdout.splitlines() if line.strip()]
|
|
350
|
-
if changed:
|
|
351
|
-
stale_reasons.append(
|
|
352
|
-
"source files changed after source commit: " + ", ".join(changed[:8])
|
|
353
|
-
)
|
|
354
|
-
else:
|
|
355
|
-
detail = first_sentence(diff.stderr.strip() or f"git diff exited {diff.returncode}")
|
|
356
|
-
stale_reasons.append("source commit could not be compared with current HEAD: " + detail)
|
|
357
|
-
|
|
358
|
-
if stale_reasons:
|
|
359
|
-
status = "stale"
|
|
360
|
-
|
|
361
|
-
identity = "|".join([project_name, archive, entry_type, title, body[:160]])
|
|
362
|
-
entry_id = ".".join([project_name, slugify(archive, 80), entry_type, short_hash(identity)])
|
|
363
|
-
archive_dir = archive_dir_from_summary(summary_path)
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
"schemaVersion": 1,
|
|
367
|
-
"id": entry_id,
|
|
368
|
-
"projectId": project_name,
|
|
369
|
-
"type": entry_type,
|
|
370
|
-
"status": status,
|
|
371
|
-
"title": title.strip(),
|
|
372
|
-
"summary": first_sentence(body),
|
|
373
|
-
"body": body.strip(),
|
|
374
|
-
"keywords": sorted({kw for kw in keywords if kw}),
|
|
375
|
-
"source": {
|
|
376
|
-
"archive": rel_to_project(project, archive_dir),
|
|
377
|
-
"summaryData": rel_to_project(project, summary_path),
|
|
378
|
-
"summarySha256": summary_hash,
|
|
379
|
-
"sourceCommit": final_commit,
|
|
380
|
-
"baseCommit": base_commit,
|
|
381
|
-
"changeName": str(summary.get("changeName") or archive),
|
|
382
|
-
"finalStatus": str(summary.get("finalStatus") or ""),
|
|
383
|
-
},
|
|
384
|
-
"scope": {
|
|
385
|
-
"sourceFiles": source_files,
|
|
386
|
-
"staleIfPathsChanged": stale_patterns(source_files),
|
|
387
|
-
},
|
|
388
|
-
"lifecycle": {
|
|
389
|
-
"createdAt": now_iso(),
|
|
390
|
-
"verifiedAt": summary.get("archivedAt") or now_iso(),
|
|
391
|
-
"lastCheckedAt": now_iso(),
|
|
392
|
-
"confidence": confidence,
|
|
393
|
-
"supersedes": [],
|
|
394
|
-
"supersededBy": None,
|
|
395
|
-
"conflictsWith": [],
|
|
396
|
-
"staleReasons": stale_reasons,
|
|
397
|
-
},
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def first_sentence(text: str) -> str:
|
|
402
|
-
clean = re.sub(r"\s+", " ", text).strip()
|
|
403
|
-
if len(clean) <= 180:
|
|
404
|
-
return clean
|
|
405
|
-
return clean[:177].rstrip() + "..."
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def stale_patterns(files: list[str]) -> list[str]:
|
|
409
|
-
patterns = []
|
|
410
|
-
for path in files:
|
|
411
|
-
if path not in patterns:
|
|
412
|
-
patterns.append(path)
|
|
413
|
-
parent = str(Path(path).parent).replace("\\", "/")
|
|
414
|
-
if parent and parent != ".":
|
|
415
|
-
pattern = parent + "/**"
|
|
416
|
-
if pattern not in patterns:
|
|
417
|
-
patterns.append(pattern)
|
|
418
|
-
return patterns
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
def keyword_candidates(summary: dict[str, Any], extra: list[str] | None = None) -> list[str]:
|
|
422
|
-
values = [
|
|
423
|
-
str(summary.get("changeName") or ""),
|
|
424
|
-
str(summary.get("businessGoal") or ""),
|
|
425
|
-
str(summary.get("finalStatus") or ""),
|
|
426
|
-
]
|
|
427
|
-
values.extend(extra or [])
|
|
428
|
-
tokens: list[str] = []
|
|
429
|
-
for value in values:
|
|
430
|
-
for token in re.findall(r"[\w\u4e00-\u9fff]{2,}", value, flags=re.UNICODE):
|
|
431
|
-
tokens.append(token.lower())
|
|
432
|
-
return tokens[:40]
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def extract_entries(project: Path, project_name: str, summary_path: Path) -> list[dict[str, Any]]:
|
|
436
|
-
summary = read_json(summary_path)
|
|
437
|
-
summary_hash = sha256_file(summary_path)
|
|
438
|
-
files = changed_file_paths(summary)
|
|
439
|
-
entries: list[dict[str, Any]] = []
|
|
440
|
-
goal = str(summary.get("businessGoal") or "").strip()
|
|
441
|
-
|
|
442
|
-
if goal:
|
|
443
|
-
entries.append(
|
|
444
|
-
make_entry(
|
|
445
|
-
project=project,
|
|
446
|
-
project_name=project_name,
|
|
447
|
-
summary_path=summary_path,
|
|
448
|
-
summary_hash=summary_hash,
|
|
449
|
-
summary=summary,
|
|
450
|
-
entry_type="requirement",
|
|
451
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)}: {first_sentence(goal)}",
|
|
452
|
-
body=goal,
|
|
453
|
-
source_files=files,
|
|
454
|
-
keywords=keyword_candidates(summary, ["requirement", "goal"]),
|
|
455
|
-
confidence="high",
|
|
456
|
-
)
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
for idx, note in enumerate(summary.get("maintenanceNotes") or [], start=1):
|
|
460
|
-
text = str(note).strip()
|
|
461
|
-
if not text:
|
|
462
|
-
continue
|
|
463
|
-
entry_type = "decision" if looks_like_decision(text) else "implementation"
|
|
464
|
-
entries.append(
|
|
465
|
-
make_entry(
|
|
466
|
-
project=project,
|
|
467
|
-
project_name=project_name,
|
|
468
|
-
summary_path=summary_path,
|
|
469
|
-
summary_hash=summary_hash,
|
|
470
|
-
summary=summary,
|
|
471
|
-
entry_type=entry_type,
|
|
472
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)} note {idx}: {first_sentence(text)}",
|
|
473
|
-
body=text,
|
|
474
|
-
source_files=files,
|
|
475
|
-
keywords=keyword_candidates(summary, ["maintenance", entry_type]),
|
|
476
|
-
confidence="medium",
|
|
477
|
-
)
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
for item in summary.get("changedFiles") or []:
|
|
481
|
-
if not isinstance(item, dict) or not item.get("path"):
|
|
482
|
-
continue
|
|
483
|
-
path = str(item.get("path"))
|
|
484
|
-
text = str(item.get("summary") or path)
|
|
485
|
-
entry_type = "api-contract" if looks_like_contract_path(path) else "implementation"
|
|
486
|
-
entries.append(
|
|
487
|
-
make_entry(
|
|
488
|
-
project=project,
|
|
489
|
-
project_name=project_name,
|
|
490
|
-
summary_path=summary_path,
|
|
491
|
-
summary_hash=summary_hash,
|
|
492
|
-
summary=summary,
|
|
493
|
-
entry_type=entry_type,
|
|
494
|
-
title=f"{path}: {first_sentence(text)}",
|
|
495
|
-
body=f"{path}: {text}",
|
|
496
|
-
source_files=[path],
|
|
497
|
-
keywords=keyword_candidates(summary, [path, entry_type]),
|
|
498
|
-
confidence="medium",
|
|
499
|
-
)
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
for idx, risk in enumerate(summary.get("knownRisks") or [], start=1):
|
|
503
|
-
text = str(risk).strip()
|
|
504
|
-
if not text:
|
|
505
|
-
continue
|
|
506
|
-
entries.append(
|
|
507
|
-
make_entry(
|
|
508
|
-
project=project,
|
|
509
|
-
project_name=project_name,
|
|
510
|
-
summary_path=summary_path,
|
|
511
|
-
summary_hash=summary_hash,
|
|
512
|
-
summary=summary,
|
|
513
|
-
entry_type="risk",
|
|
514
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)} risk {idx}: {first_sentence(text)}",
|
|
515
|
-
body=text,
|
|
516
|
-
source_files=files,
|
|
517
|
-
keywords=keyword_candidates(summary, ["risk"]),
|
|
518
|
-
confidence="medium",
|
|
519
|
-
)
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
for idx, action in enumerate(summary.get("manualActions") or [], start=1):
|
|
523
|
-
text = str(action).strip()
|
|
524
|
-
if not text:
|
|
525
|
-
continue
|
|
526
|
-
entries.append(
|
|
527
|
-
make_entry(
|
|
528
|
-
project=project,
|
|
529
|
-
project_name=project_name,
|
|
530
|
-
summary_path=summary_path,
|
|
531
|
-
summary_hash=summary_hash,
|
|
532
|
-
summary=summary,
|
|
533
|
-
entry_type="risk",
|
|
534
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)} manual action {idx}: {first_sentence(text)}",
|
|
535
|
-
body="Manual action: " + text,
|
|
536
|
-
source_files=files,
|
|
537
|
-
keywords=keyword_candidates(summary, ["manual", "action", "risk"]),
|
|
538
|
-
confidence="medium",
|
|
539
|
-
)
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
verification = summary.get("verification")
|
|
543
|
-
if verification:
|
|
544
|
-
body = json.dumps(verification, ensure_ascii=False, indent=2)
|
|
545
|
-
entries.append(
|
|
546
|
-
make_entry(
|
|
547
|
-
project=project,
|
|
548
|
-
project_name=project_name,
|
|
549
|
-
summary_path=summary_path,
|
|
550
|
-
summary_hash=summary_hash,
|
|
551
|
-
summary=summary,
|
|
552
|
-
entry_type="test-evidence",
|
|
553
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)} verification evidence",
|
|
554
|
-
body=body,
|
|
555
|
-
source_files=files,
|
|
556
|
-
keywords=keyword_candidates(summary, ["test", "verification", "evidence"]),
|
|
557
|
-
confidence="high",
|
|
558
|
-
)
|
|
559
|
-
)
|
|
560
|
-
|
|
561
|
-
review = summary.get("reviewSummary")
|
|
562
|
-
if isinstance(review, dict) and review.get("summary"):
|
|
563
|
-
body = str(review.get("summary"))
|
|
564
|
-
entries.append(
|
|
565
|
-
make_entry(
|
|
566
|
-
project=project,
|
|
567
|
-
project_name=project_name,
|
|
568
|
-
summary_path=summary_path,
|
|
569
|
-
summary_hash=summary_hash,
|
|
570
|
-
summary=summary,
|
|
571
|
-
entry_type="risk",
|
|
572
|
-
title=f"{summary.get('changeName') or archive_name(summary_path)} review: {first_sentence(body)}",
|
|
573
|
-
body=body,
|
|
574
|
-
source_files=files,
|
|
575
|
-
keywords=keyword_candidates(summary, ["review", "risk"]),
|
|
576
|
-
confidence="medium",
|
|
577
|
-
)
|
|
578
|
-
)
|
|
579
|
-
|
|
580
|
-
return entries
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def looks_like_decision(text: str) -> bool:
|
|
584
|
-
needles = ["不新增", "唯一", "保持", "改为", "取代", "决策", "wontfix", "tradeoff", "instead"]
|
|
585
|
-
lower = text.lower()
|
|
586
|
-
return any(needle in lower for needle in needles)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
def looks_like_contract_path(path: str) -> bool:
|
|
590
|
-
lower = path.lower()
|
|
591
|
-
return any(part in lower for part in ["openapi", "contract", "schema", "protocol", "api"])
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
def reset_generated_knowledge(knowledge: Path) -> None:
|
|
595
|
-
for sub in ["entries/candidate", "entries/stale", "entries/conflicted", "entries/superseded"]:
|
|
596
|
-
path = knowledge / sub
|
|
597
|
-
if path.exists():
|
|
598
|
-
if sub in {"entries/candidate", "entries/stale"}:
|
|
599
|
-
for entry_path in sorted(path.glob("*.json")):
|
|
600
|
-
try:
|
|
601
|
-
entry = read_json(entry_path)
|
|
602
|
-
except (OSError, json.JSONDecodeError):
|
|
603
|
-
entry_path.unlink(missing_ok=True)
|
|
604
|
-
continue
|
|
605
|
-
if not isinstance(entry, dict) or not entry.get("lifecycle", {}).get("demotedAt"):
|
|
606
|
-
entry_path.unlink(missing_ok=True)
|
|
607
|
-
else:
|
|
608
|
-
shutil.rmtree(path)
|
|
609
|
-
for sub in [
|
|
610
|
-
"entries/candidate",
|
|
611
|
-
"entries/active",
|
|
612
|
-
"entries/stale",
|
|
613
|
-
"entries/superseded",
|
|
614
|
-
"entries/conflicted",
|
|
615
|
-
"cache/archive-entries",
|
|
616
|
-
"reports",
|
|
617
|
-
"views",
|
|
618
|
-
"context-packs",
|
|
619
|
-
]:
|
|
620
|
-
(knowledge / sub).mkdir(parents=True, exist_ok=True)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
def load_entries_from_dir(path: Path) -> list[dict[str, Any]]:
|
|
624
|
-
entries: list[dict[str, Any]] = []
|
|
625
|
-
if not path.exists():
|
|
626
|
-
return entries
|
|
627
|
-
for entry_path in sorted(path.glob("*.json")):
|
|
628
|
-
try:
|
|
629
|
-
entry = read_json(entry_path)
|
|
630
|
-
except (OSError, json.JSONDecodeError):
|
|
631
|
-
continue
|
|
632
|
-
if isinstance(entry, dict) and entry.get("id"):
|
|
633
|
-
entries.append(entry)
|
|
634
|
-
return entries
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def archive_entry_cache_path(project: Path, knowledge: Path, summary_path: Path, summary_hash: str) -> Path:
|
|
638
|
-
rel_summary = rel_to_project(project, summary_path)
|
|
639
|
-
cache_id = short_hash(rel_summary + "|" + summary_hash, 20)
|
|
640
|
-
filename = f"{safe_filename(archive_name(summary_path))}-{cache_id}.json"
|
|
641
|
-
return knowledge / "cache" / "archive-entries" / filename
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def load_cached_archive_entries(
|
|
645
|
-
cache_path: Path,
|
|
646
|
-
*,
|
|
647
|
-
summary_path: str,
|
|
648
|
-
summary_hash: str,
|
|
649
|
-
head_commit: str | None,
|
|
650
|
-
) -> list[dict[str, Any]] | None:
|
|
651
|
-
if not cache_path.exists():
|
|
652
|
-
return None
|
|
653
|
-
try:
|
|
654
|
-
payload = read_json(cache_path)
|
|
655
|
-
except (OSError, json.JSONDecodeError):
|
|
656
|
-
return None
|
|
657
|
-
if not isinstance(payload, dict):
|
|
658
|
-
return None
|
|
659
|
-
if payload.get("schemaVersion") != 1:
|
|
660
|
-
return None
|
|
661
|
-
if payload.get("summaryData") != summary_path:
|
|
662
|
-
return None
|
|
663
|
-
if payload.get("summarySha256") != summary_hash:
|
|
664
|
-
return None
|
|
665
|
-
if payload.get("headCommit") != head_commit:
|
|
666
|
-
return None
|
|
667
|
-
entries = payload.get("entries")
|
|
668
|
-
if not isinstance(entries, list):
|
|
669
|
-
return None
|
|
670
|
-
if not all(isinstance(entry, dict) and entry.get("id") for entry in entries):
|
|
671
|
-
return None
|
|
672
|
-
return json_clone(entries)
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def write_cached_archive_entries(
|
|
676
|
-
cache_path: Path,
|
|
677
|
-
*,
|
|
678
|
-
summary_path: str,
|
|
679
|
-
summary_hash: str,
|
|
680
|
-
head_commit: str | None,
|
|
681
|
-
entries: list[dict[str, Any]],
|
|
682
|
-
) -> None:
|
|
683
|
-
write_json(
|
|
684
|
-
cache_path,
|
|
685
|
-
{
|
|
686
|
-
"schemaVersion": 1,
|
|
687
|
-
"generatedAt": now_iso(),
|
|
688
|
-
"summaryData": summary_path,
|
|
689
|
-
"summarySha256": summary_hash,
|
|
690
|
-
"headCommit": head_commit,
|
|
691
|
-
"entries": entries,
|
|
692
|
-
},
|
|
693
|
-
)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def load_preserved_entries(knowledge: Path) -> list[dict[str, Any]]:
|
|
697
|
-
active_entries = load_entries_from_dir(knowledge / "entries" / "active")
|
|
698
|
-
demoted_entries = [
|
|
699
|
-
entry
|
|
700
|
-
for status in ["candidate", "stale"]
|
|
701
|
-
for entry in load_entries_from_dir(knowledge / "entries" / status)
|
|
702
|
-
if entry.get("lifecycle", {}).get("demotedAt")
|
|
703
|
-
]
|
|
704
|
-
return active_entries + demoted_entries
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
def combine_generated_with_preserved(knowledge: Path, generated: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
708
|
-
preserved_entries = load_preserved_entries(knowledge)
|
|
709
|
-
preserved_ids = {entry["id"] for entry in preserved_entries}
|
|
710
|
-
return preserved_entries + [entry for entry in generated if entry["id"] not in preserved_ids]
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
def archive_sort_key(entry: dict[str, Any]) -> tuple[str, str]:
|
|
714
|
-
archive = entry.get("source", {}).get("archive", "")
|
|
715
|
-
match = re.search(r"(\d{4}-\d{2}-\d{2})", archive)
|
|
716
|
-
return (match.group(1) if match else "", archive)
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
def archive_date(entry: dict[str, Any]) -> dt.date | None:
|
|
720
|
-
date_text = archive_sort_key(entry)[0]
|
|
721
|
-
if not date_text:
|
|
722
|
-
return None
|
|
723
|
-
try:
|
|
724
|
-
return dt.date.fromisoformat(date_text)
|
|
725
|
-
except ValueError:
|
|
726
|
-
return None
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
def clamp_score(value: float) -> float:
|
|
730
|
-
return max(0.0, min(1.0, value))
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def confidence_level(score: float) -> str:
|
|
734
|
-
if score >= 0.82:
|
|
735
|
-
return "high"
|
|
736
|
-
if score >= 0.55:
|
|
737
|
-
return "medium"
|
|
738
|
-
return "low"
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
def entry_age_days(entry: dict[str, Any]) -> int | None:
|
|
742
|
-
date = archive_date(entry)
|
|
743
|
-
if date is None:
|
|
744
|
-
return None
|
|
745
|
-
return max(0, (dt.date.today() - date).days)
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
def validation_status(entry: dict[str, Any]) -> str | None:
|
|
749
|
-
validation = entry.get("lifecycle", {}).get("validation")
|
|
750
|
-
if not isinstance(validation, dict):
|
|
751
|
-
return None
|
|
752
|
-
status = validation.get("status")
|
|
753
|
-
return str(status) if status else None
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
def calculate_confidence(entry: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
|
757
|
-
policy = confidence_config(config)
|
|
758
|
-
legacy = str(entry.get("lifecycle", {}).get("confidence") or "medium")
|
|
759
|
-
score = {"high": 0.76, "medium": 0.58, "low": 0.36}.get(legacy, 0.58)
|
|
760
|
-
signals = [f"base:{legacy}"]
|
|
761
|
-
|
|
762
|
-
type_bonus = {
|
|
763
|
-
"decision": 0.14,
|
|
764
|
-
"api-contract": 0.14,
|
|
765
|
-
"requirement": 0.12,
|
|
766
|
-
"pitfall": 0.10,
|
|
767
|
-
"test-evidence": 0.04,
|
|
768
|
-
"implementation": 0.00,
|
|
769
|
-
"risk": -0.04,
|
|
770
|
-
}.get(str(entry.get("type")), 0.0)
|
|
771
|
-
if type_bonus:
|
|
772
|
-
score += type_bonus
|
|
773
|
-
signals.append(f"type_bonus:{entry.get('type')}:{type_bonus:+.2f}")
|
|
774
|
-
|
|
775
|
-
final_status = str(entry.get("source", {}).get("finalStatus") or "").strip().lower()
|
|
776
|
-
if final_status in {"ok", "success", "passed", "pass"}:
|
|
777
|
-
score += 0.04
|
|
778
|
-
signals.append("source_final_status_ok:+0.04")
|
|
779
|
-
elif final_status in {"fail", "failed", "error", "warn"}:
|
|
780
|
-
score -= 0.08
|
|
781
|
-
signals.append("source_final_status_not_ok:-0.08")
|
|
782
|
-
|
|
783
|
-
if entry.get("scope", {}).get("sourceFiles"):
|
|
784
|
-
score += 0.03
|
|
785
|
-
signals.append("has_source_files:+0.03")
|
|
786
|
-
|
|
787
|
-
text = normalized_entry_text(entry)
|
|
788
|
-
if has_stability_signal(text) or str(entry.get("type")) in {"decision", "api-contract"}:
|
|
789
|
-
score += 0.06
|
|
790
|
-
signals.append("long_lived_signal:+0.06")
|
|
791
|
-
|
|
792
|
-
status = str(entry.get("status") or "")
|
|
793
|
-
stale_reasons = entry.get("lifecycle", {}).get("staleReasons") or []
|
|
794
|
-
if status == "stale":
|
|
795
|
-
score -= policy["stalePenalty"]
|
|
796
|
-
signals.append("status_stale_penalty")
|
|
797
|
-
if any("source files changed" in str(reason) for reason in stale_reasons):
|
|
798
|
-
score -= policy["sourceChangePenalty"]
|
|
799
|
-
signals.append("source_change_penalty")
|
|
800
|
-
if status == "superseded" or entry.get("lifecycle", {}).get("supersededBy"):
|
|
801
|
-
score -= policy["supersededPenalty"]
|
|
802
|
-
signals.append("superseded_penalty")
|
|
803
|
-
if status == "conflicted" or entry.get("lifecycle", {}).get("conflictsWith"):
|
|
804
|
-
score -= policy["conflictPenalty"]
|
|
805
|
-
signals.append("conflict_penalty")
|
|
806
|
-
|
|
807
|
-
validator_status = validation_status(entry)
|
|
808
|
-
if validator_status == "passed":
|
|
809
|
-
score += policy["validatorPassBonus"]
|
|
810
|
-
signals.append("validator_pass_bonus")
|
|
811
|
-
elif validator_status == "failed":
|
|
812
|
-
score -= policy["validatorFailPenalty"]
|
|
813
|
-
signals.append("validator_fail_penalty")
|
|
814
|
-
elif validator_status == "skipped":
|
|
815
|
-
score -= 0.05
|
|
816
|
-
signals.append("validator_skipped_penalty")
|
|
817
|
-
|
|
818
|
-
age_days = entry_age_days(entry)
|
|
819
|
-
if age_days is not None:
|
|
820
|
-
age_penalty = min(0.25, (age_days / policy["ttlHalfLifeDays"]) * 0.06)
|
|
821
|
-
if age_penalty:
|
|
822
|
-
score -= age_penalty
|
|
823
|
-
signals.append(f"age_penalty:{age_days}d:-{age_penalty:.2f}")
|
|
824
|
-
|
|
825
|
-
score = clamp_score(score)
|
|
826
|
-
return {
|
|
827
|
-
"score": round(score, 3),
|
|
828
|
-
"level": confidence_level(score),
|
|
829
|
-
"signals": signals,
|
|
830
|
-
"lastCalculatedAt": now_iso(),
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def apply_confidence_scores(entries: list[dict[str, Any]], config: dict[str, Any]) -> None:
|
|
835
|
-
for entry in entries:
|
|
836
|
-
entry["confidence"] = calculate_confidence(entry, config)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
def should_auto_promote(entry: dict[str, Any], policy: dict[str, Any]) -> bool:
|
|
840
|
-
if not policy["enabled"]:
|
|
841
|
-
return False
|
|
842
|
-
if entry.get("type") not in set(policy["allowedTypes"]):
|
|
843
|
-
return False
|
|
844
|
-
if entry.get("status") == "stale" and not policy["allowStale"]:
|
|
845
|
-
return False
|
|
846
|
-
if entry.get("status") not in {"candidate", "stale"}:
|
|
847
|
-
return False
|
|
848
|
-
if entry.get("status") == "candidate" and entry.get("lifecycle", {}).get("staleReasons"):
|
|
849
|
-
return False
|
|
850
|
-
confidence = entry.get("confidence") if isinstance(entry.get("confidence"), dict) else {}
|
|
851
|
-
if float(confidence.get("score") or 0.0) < policy["minConfidence"]:
|
|
852
|
-
return False
|
|
853
|
-
if policy["requireValidators"] and validation_status(entry) != "passed":
|
|
854
|
-
return False
|
|
855
|
-
if entry.get("lifecycle", {}).get("conflictsWith") or entry.get("lifecycle", {}).get("supersededBy"):
|
|
856
|
-
return False
|
|
857
|
-
return True
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
def apply_auto_promote_policy(entries: list[dict[str, Any]], config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
861
|
-
policy = auto_promote_config(config)
|
|
862
|
-
if not policy["enabled"] or policy["maxPerRun"] <= 0:
|
|
863
|
-
return []
|
|
864
|
-
eligible = [entry for entry in entries if should_auto_promote(entry, policy)]
|
|
865
|
-
eligible.sort(
|
|
866
|
-
key=lambda entry: (
|
|
867
|
-
float(entry.get("confidence", {}).get("score") or 0.0),
|
|
868
|
-
archive_sort_key(entry),
|
|
869
|
-
entry.get("id", ""),
|
|
870
|
-
),
|
|
871
|
-
reverse=True,
|
|
872
|
-
)
|
|
873
|
-
actions: list[dict[str, Any]] = []
|
|
874
|
-
for entry in eligible[: policy["maxPerRun"]]:
|
|
875
|
-
entry["status"] = "active"
|
|
876
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
877
|
-
score = float(entry.get("confidence", {}).get("score") or 0.0)
|
|
878
|
-
note = f"autoPromote: confidence {score:.3f} >= {policy['minConfidence']:.3f}"
|
|
879
|
-
lifecycle["promotedAt"] = now_iso()
|
|
880
|
-
lifecycle["promotionNote"] = note
|
|
881
|
-
lifecycle["autoPromoted"] = True
|
|
882
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
883
|
-
confidence = entry.setdefault("confidence", {})
|
|
884
|
-
signals = confidence.setdefault("signals", [])
|
|
885
|
-
if isinstance(signals, list):
|
|
886
|
-
signals.append("auto_promoted")
|
|
887
|
-
actions.append(
|
|
888
|
-
{
|
|
889
|
-
"id": entry["id"],
|
|
890
|
-
"type": entry.get("type"),
|
|
891
|
-
"score": score,
|
|
892
|
-
"status": "active",
|
|
893
|
-
"reason": note,
|
|
894
|
-
}
|
|
895
|
-
)
|
|
896
|
-
return actions
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
def persist_entry_updates(knowledge: Path, entries: list[dict[str, Any]]) -> None:
|
|
900
|
-
for entry in entries:
|
|
901
|
-
status = str(entry.get("status") or "")
|
|
902
|
-
if status not in {"candidate", "active", "stale", "superseded", "conflicted"}:
|
|
903
|
-
continue
|
|
904
|
-
path = knowledge / "entries" / status / entry_filename(entry)
|
|
905
|
-
if path.exists():
|
|
906
|
-
write_json(path, entry)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
def normalized_entry_text(entry: dict[str, Any]) -> str:
|
|
910
|
-
return " ".join(
|
|
911
|
-
str(value or "")
|
|
912
|
-
for value in [
|
|
913
|
-
entry.get("title"),
|
|
914
|
-
entry.get("summary"),
|
|
915
|
-
entry.get("body"),
|
|
916
|
-
" ".join(entry.get("keywords") or []),
|
|
917
|
-
]
|
|
918
|
-
).lower()
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
def subject_terms(entry: dict[str, Any]) -> set[str]:
|
|
922
|
-
text = normalized_entry_text(entry)
|
|
923
|
-
terms = set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_.-]{2,}", text))
|
|
924
|
-
stop = {
|
|
925
|
-
"the",
|
|
926
|
-
"and",
|
|
927
|
-
"for",
|
|
928
|
-
"with",
|
|
929
|
-
"from",
|
|
930
|
-
"this",
|
|
931
|
-
"that",
|
|
932
|
-
"status",
|
|
933
|
-
"note",
|
|
934
|
-
"risk",
|
|
935
|
-
"requirement",
|
|
936
|
-
"implementation",
|
|
937
|
-
}
|
|
938
|
-
return {term for term in terms if term not in stop}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
def has_replacement_signal(text: str) -> bool:
|
|
942
|
-
needles = [
|
|
943
|
-
"不再",
|
|
944
|
-
"替代",
|
|
945
|
-
"取代",
|
|
946
|
-
"改为",
|
|
947
|
-
"移除",
|
|
948
|
-
"废弃",
|
|
949
|
-
"deprecated",
|
|
950
|
-
"instead",
|
|
951
|
-
"replace",
|
|
952
|
-
"replaced",
|
|
953
|
-
"remove",
|
|
954
|
-
"removed",
|
|
955
|
-
]
|
|
956
|
-
return any(needle in text for needle in needles)
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
def has_stability_signal(text: str) -> bool:
|
|
960
|
-
needles = ["唯一", "复用", "保持", "source of truth", "single source", "only source"]
|
|
961
|
-
return any(needle in text for needle in needles)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
def entries_conflict(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
965
|
-
comparable_types = {"requirement", "decision", "api-contract"}
|
|
966
|
-
if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
|
|
967
|
-
return False
|
|
968
|
-
if left.get("status") in {"active", "stale", "superseded", "conflicted"}:
|
|
969
|
-
return False
|
|
970
|
-
if right.get("status") in {"active", "stale", "superseded", "conflicted"}:
|
|
971
|
-
return False
|
|
972
|
-
|
|
973
|
-
left_files = set(left.get("scope", {}).get("sourceFiles") or [])
|
|
974
|
-
right_files = set(right.get("scope", {}).get("sourceFiles") or [])
|
|
975
|
-
if not left_files or not right_files or not (left_files & right_files):
|
|
976
|
-
return False
|
|
977
|
-
|
|
978
|
-
shared_terms = subject_terms(left) & subject_terms(right)
|
|
979
|
-
if not shared_terms:
|
|
980
|
-
return False
|
|
981
|
-
|
|
982
|
-
left_text = normalized_entry_text(left)
|
|
983
|
-
right_text = normalized_entry_text(right)
|
|
984
|
-
return (
|
|
985
|
-
has_replacement_signal(left_text)
|
|
986
|
-
and has_stability_signal(right_text)
|
|
987
|
-
) or (
|
|
988
|
-
has_replacement_signal(right_text)
|
|
989
|
-
and has_stability_signal(left_text)
|
|
990
|
-
)
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
def entries_conflict_for_review(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
994
|
-
comparable_types = {"requirement", "decision", "api-contract"}
|
|
995
|
-
if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
|
|
996
|
-
return False
|
|
997
|
-
|
|
998
|
-
left_files = set(left.get("scope", {}).get("sourceFiles") or [])
|
|
999
|
-
right_files = set(right.get("scope", {}).get("sourceFiles") or [])
|
|
1000
|
-
if not left_files or not right_files or not (left_files & right_files):
|
|
1001
|
-
return False
|
|
1002
|
-
|
|
1003
|
-
shared_terms = subject_terms(left) & subject_terms(right)
|
|
1004
|
-
if not shared_terms:
|
|
1005
|
-
return False
|
|
1006
|
-
|
|
1007
|
-
left_text = normalized_entry_text(left)
|
|
1008
|
-
right_text = normalized_entry_text(right)
|
|
1009
|
-
return (
|
|
1010
|
-
has_replacement_signal(left_text)
|
|
1011
|
-
and has_stability_signal(right_text)
|
|
1012
|
-
) or (
|
|
1013
|
-
has_replacement_signal(right_text)
|
|
1014
|
-
and has_stability_signal(left_text)
|
|
1015
|
-
)
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
def mark_conflict(left: dict[str, Any], right: dict[str, Any]) -> None:
|
|
1019
|
-
for entry, other in [(left, right), (right, left)]:
|
|
1020
|
-
entry["status"] = "conflicted"
|
|
1021
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1022
|
-
conflicts = lifecycle.setdefault("conflictsWith", [])
|
|
1023
|
-
if other["id"] not in conflicts:
|
|
1024
|
-
conflicts.append(other["id"])
|
|
1025
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
1026
|
-
reason = "potential conflict with: " + other["id"]
|
|
1027
|
-
if reason not in reasons:
|
|
1028
|
-
reasons.append(reason)
|
|
1029
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
def mark_conflicting_generated_entries(entries: list[dict[str, Any]]) -> None:
|
|
1033
|
-
ordered = sorted(entries, key=archive_sort_key)
|
|
1034
|
-
for idx, left in enumerate(ordered):
|
|
1035
|
-
for right in ordered[idx + 1 :]:
|
|
1036
|
-
if entries_conflict(left, right):
|
|
1037
|
-
mark_conflict(left, right)
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
def number_value(value: Any) -> int | None:
|
|
1041
|
-
if isinstance(value, bool):
|
|
1042
|
-
return None
|
|
1043
|
-
if isinstance(value, int):
|
|
1044
|
-
return value
|
|
1045
|
-
if isinstance(value, float):
|
|
1046
|
-
return int(value)
|
|
1047
|
-
if isinstance(value, str):
|
|
1048
|
-
value = value.strip()
|
|
1049
|
-
if value.isdigit():
|
|
1050
|
-
return int(value)
|
|
1051
|
-
return None
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
def parse_pass_rate(value: Any) -> tuple[int | None, int | None]:
|
|
1055
|
-
if not isinstance(value, str):
|
|
1056
|
-
return (None, None)
|
|
1057
|
-
match = re.search(r"(\d+)\s*/\s*(\d+)", value)
|
|
1058
|
-
if not match:
|
|
1059
|
-
return (None, None)
|
|
1060
|
-
return (int(match.group(1)), int(match.group(2)))
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
def collect_verification_metrics(value: Any) -> dict[str, Any]:
|
|
1064
|
-
metrics: dict[str, Any] = {
|
|
1065
|
-
"failed": 0,
|
|
1066
|
-
"passed": 0,
|
|
1067
|
-
"total": 0,
|
|
1068
|
-
"failedStatus": False,
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
def visit(node: Any) -> None:
|
|
1072
|
-
if isinstance(node, dict):
|
|
1073
|
-
local_passed = None
|
|
1074
|
-
local_total = None
|
|
1075
|
-
local_ratio_found = False
|
|
1076
|
-
for key, raw in node.items():
|
|
1077
|
-
key_l = str(key).lower()
|
|
1078
|
-
if key_l in {"failures", "failed", "failure", "errors", "error"}:
|
|
1079
|
-
number = number_value(raw)
|
|
1080
|
-
if number is not None:
|
|
1081
|
-
metrics["failed"] += number
|
|
1082
|
-
elif key_l in {"passed", "pass", "successes", "succeeded"}:
|
|
1083
|
-
local_passed = number_value(raw)
|
|
1084
|
-
elif key_l in {"total", "run", "tests", "count"}:
|
|
1085
|
-
local_total = number_value(raw)
|
|
1086
|
-
elif key_l in {"passrate", "pass_rate"}:
|
|
1087
|
-
passed, total = parse_pass_rate(raw)
|
|
1088
|
-
if passed is not None and total is not None:
|
|
1089
|
-
metrics["passed"] += passed
|
|
1090
|
-
metrics["total"] += total
|
|
1091
|
-
local_ratio_found = True
|
|
1092
|
-
elif key_l == "status" and isinstance(raw, str):
|
|
1093
|
-
status = raw.strip().lower()
|
|
1094
|
-
if status in {"fail", "failed", "failure", "error", "errored", "red"}:
|
|
1095
|
-
metrics["failedStatus"] = True
|
|
1096
|
-
visit(raw)
|
|
1097
|
-
if not local_ratio_found and local_passed is not None and local_total is not None:
|
|
1098
|
-
metrics["passed"] += local_passed
|
|
1099
|
-
metrics["total"] += local_total
|
|
1100
|
-
elif isinstance(node, list):
|
|
1101
|
-
for item in node:
|
|
1102
|
-
visit(item)
|
|
1103
|
-
|
|
1104
|
-
visit(value)
|
|
1105
|
-
if metrics["total"]:
|
|
1106
|
-
metrics["passRatio"] = metrics["passed"] / metrics["total"]
|
|
1107
|
-
else:
|
|
1108
|
-
metrics["passRatio"] = None
|
|
1109
|
-
return metrics
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
def verification_metrics(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
1113
|
-
try:
|
|
1114
|
-
verification = json.loads(entry.get("body", "") or "{}")
|
|
1115
|
-
except json.JSONDecodeError:
|
|
1116
|
-
return None
|
|
1117
|
-
if not isinstance(verification, (dict, list)):
|
|
1118
|
-
return None
|
|
1119
|
-
return collect_verification_metrics(verification)
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
def verification_is_degraded(old_metrics: dict[str, Any] | None, new_metrics: dict[str, Any] | None) -> bool:
|
|
1123
|
-
if not new_metrics:
|
|
1124
|
-
return False
|
|
1125
|
-
if new_metrics.get("failedStatus") or int(new_metrics.get("failed") or 0) > 0:
|
|
1126
|
-
return True
|
|
1127
|
-
if not old_metrics:
|
|
1128
|
-
return False
|
|
1129
|
-
old_ratio = old_metrics.get("passRatio")
|
|
1130
|
-
new_ratio = new_metrics.get("passRatio")
|
|
1131
|
-
if isinstance(old_ratio, (int, float)) and isinstance(new_ratio, (int, float)):
|
|
1132
|
-
return new_ratio < old_ratio
|
|
1133
|
-
return False
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
def verification_degradation_detail(metrics: dict[str, Any]) -> str:
|
|
1137
|
-
parts: list[str] = []
|
|
1138
|
-
if metrics.get("failedStatus"):
|
|
1139
|
-
parts.append("failed status")
|
|
1140
|
-
failed = int(metrics.get("failed") or 0)
|
|
1141
|
-
if failed:
|
|
1142
|
-
parts.append(f"failures/errors={failed}")
|
|
1143
|
-
ratio = metrics.get("passRatio")
|
|
1144
|
-
if isinstance(ratio, (int, float)):
|
|
1145
|
-
parts.append(f"passRatio={ratio:.2f}")
|
|
1146
|
-
return ", ".join(parts) or "verification metrics worsened"
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
def mark_degraded_test_evidence(entries: list[dict[str, Any]]) -> None:
|
|
1150
|
-
ordered = sorted(entries, key=archive_sort_key)
|
|
1151
|
-
for idx, older in enumerate(ordered):
|
|
1152
|
-
if older.get("type") != "test-evidence":
|
|
1153
|
-
continue
|
|
1154
|
-
if older.get("status") in {"active", "superseded", "conflicted"}:
|
|
1155
|
-
continue
|
|
1156
|
-
older_files = set(older.get("scope", {}).get("sourceFiles") or [])
|
|
1157
|
-
if not older_files:
|
|
1158
|
-
continue
|
|
1159
|
-
old_metrics = verification_metrics(older)
|
|
1160
|
-
for newer in ordered[idx + 1 :]:
|
|
1161
|
-
if newer.get("type") != "test-evidence":
|
|
1162
|
-
continue
|
|
1163
|
-
if archive_sort_key(newer) <= archive_sort_key(older):
|
|
1164
|
-
continue
|
|
1165
|
-
newer_files = set(newer.get("scope", {}).get("sourceFiles") or [])
|
|
1166
|
-
if not newer_files or not (older_files & newer_files):
|
|
1167
|
-
continue
|
|
1168
|
-
new_metrics = verification_metrics(newer)
|
|
1169
|
-
if not verification_is_degraded(old_metrics, new_metrics):
|
|
1170
|
-
continue
|
|
1171
|
-
older["status"] = "stale"
|
|
1172
|
-
lifecycle = older.setdefault("lifecycle", {})
|
|
1173
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
1174
|
-
detail = verification_degradation_detail(new_metrics or {})
|
|
1175
|
-
reason = "newer verification degraded: " + newer.get("source", {}).get("archive", "")
|
|
1176
|
-
if detail:
|
|
1177
|
-
reason += f" ({detail})"
|
|
1178
|
-
if reason not in reasons:
|
|
1179
|
-
reasons.append(reason)
|
|
1180
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1181
|
-
break
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
def active_review_items(entries: list[dict[str, Any]], limit: int | None = None) -> list[dict[str, Any]]:
|
|
1185
|
-
comparable_types = {"requirement", "decision", "implementation", "api-contract"}
|
|
1186
|
-
ordered = sorted(entries, key=archive_sort_key)
|
|
1187
|
-
items: list[dict[str, Any]] = []
|
|
1188
|
-
for active in [entry for entry in ordered if entry.get("status") == "active"]:
|
|
1189
|
-
reasons: list[str] = []
|
|
1190
|
-
active_files = set(active.get("scope", {}).get("sourceFiles") or [])
|
|
1191
|
-
if not active_files:
|
|
1192
|
-
continue
|
|
1193
|
-
active_metrics = verification_metrics(active) if active.get("type") == "test-evidence" else None
|
|
1194
|
-
for newer in ordered:
|
|
1195
|
-
if newer.get("id") == active.get("id"):
|
|
1196
|
-
continue
|
|
1197
|
-
if archive_sort_key(newer) <= archive_sort_key(active):
|
|
1198
|
-
continue
|
|
1199
|
-
newer_files = set(newer.get("scope", {}).get("sourceFiles") or [])
|
|
1200
|
-
if not newer_files or not (active_files & newer_files):
|
|
1201
|
-
continue
|
|
1202
|
-
if entries_conflict_for_review(active, newer):
|
|
1203
|
-
reasons.append(f"requires manual review: potential conflict with {newer['id']}")
|
|
1204
|
-
elif active.get("type") in comparable_types and newer.get("type") in comparable_types:
|
|
1205
|
-
reasons.append(
|
|
1206
|
-
"requires manual review: newer overlapping archive may supersede active entry: "
|
|
1207
|
-
+ newer.get("source", {}).get("archive", "")
|
|
1208
|
-
)
|
|
1209
|
-
if active.get("type") == "test-evidence" and newer.get("type") == "test-evidence":
|
|
1210
|
-
newer_metrics = verification_metrics(newer)
|
|
1211
|
-
if verification_is_degraded(active_metrics, newer_metrics):
|
|
1212
|
-
reasons.append(
|
|
1213
|
-
"requires manual review: newer verification degraded: "
|
|
1214
|
-
+ newer.get("source", {}).get("archive", "")
|
|
1215
|
-
)
|
|
1216
|
-
if reasons:
|
|
1217
|
-
item = json_clone(active)
|
|
1218
|
-
item["reviewReasons"] = sorted(set(reasons))
|
|
1219
|
-
items.append(item)
|
|
1220
|
-
ranked = sorted(items, key=lambda entry: (len(entry.get("reviewReasons") or []), archive_sort_key(entry)), reverse=True)
|
|
1221
|
-
return ranked[:limit] if limit is not None else ranked
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
def apply_active_lifecycle_policy(
|
|
1225
|
-
knowledge: Path,
|
|
1226
|
-
entries: list[dict[str, Any]],
|
|
1227
|
-
config: dict[str, Any],
|
|
1228
|
-
) -> list[dict[str, Any]]:
|
|
1229
|
-
policy = active_lifecycle_config(config)
|
|
1230
|
-
if not policy["autoDemote"]:
|
|
1231
|
-
return []
|
|
1232
|
-
|
|
1233
|
-
target_status = policy["targetStatus"]
|
|
1234
|
-
actions: list[dict[str, Any]] = []
|
|
1235
|
-
for review_entry in active_review_items(entries):
|
|
1236
|
-
found = find_entry_file(knowledge, review_entry["id"], ["active"])
|
|
1237
|
-
if found is None:
|
|
1238
|
-
continue
|
|
1239
|
-
source_path, entry = found
|
|
1240
|
-
reasons = review_entry.get("reviewReasons") or []
|
|
1241
|
-
reason = "activeLifecycle auto-demotion"
|
|
1242
|
-
if reasons:
|
|
1243
|
-
reason += ": " + str(reasons[0])
|
|
1244
|
-
entry["status"] = target_status
|
|
1245
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1246
|
-
lifecycle["demotedAt"] = now_iso()
|
|
1247
|
-
lifecycle["demotionReason"] = reason
|
|
1248
|
-
lifecycle["autoDemoted"] = True
|
|
1249
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1250
|
-
if target_status == "stale":
|
|
1251
|
-
stale_reasons = lifecycle.setdefault("staleReasons", [])
|
|
1252
|
-
stale_reason = "auto demotion: " + reason
|
|
1253
|
-
if stale_reason not in stale_reasons:
|
|
1254
|
-
stale_reasons.append(stale_reason)
|
|
1255
|
-
target_path = knowledge / "entries" / target_status / entry_filename(entry)
|
|
1256
|
-
write_json(target_path, entry)
|
|
1257
|
-
if source_path != target_path and source_path.exists():
|
|
1258
|
-
source_path.unlink()
|
|
1259
|
-
actions.append(
|
|
1260
|
-
{
|
|
1261
|
-
"id": entry["id"],
|
|
1262
|
-
"status": target_status,
|
|
1263
|
-
"reason": reason,
|
|
1264
|
-
"path": str(target_path),
|
|
1265
|
-
}
|
|
1266
|
-
)
|
|
1267
|
-
return actions
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
def load_entry_files(
|
|
1271
|
-
knowledge: Path,
|
|
1272
|
-
statuses: list[str] | None = None,
|
|
1273
|
-
) -> list[tuple[Path, dict[str, Any]]]:
|
|
1274
|
-
selected = statuses or ["candidate", "active", "stale", "superseded", "conflicted"]
|
|
1275
|
-
found: list[tuple[Path, dict[str, Any]]] = []
|
|
1276
|
-
for status in selected:
|
|
1277
|
-
for path in sorted((knowledge / "entries" / status).glob("*.json")):
|
|
1278
|
-
try:
|
|
1279
|
-
entry = read_json(path)
|
|
1280
|
-
except (OSError, json.JSONDecodeError):
|
|
1281
|
-
continue
|
|
1282
|
-
if isinstance(entry, dict) and entry.get("id"):
|
|
1283
|
-
found.append((path, entry))
|
|
1284
|
-
return found
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
def safe_project_path(project: Path, rel_path: str) -> Path | None:
|
|
1288
|
-
if not rel_path:
|
|
1289
|
-
return None
|
|
1290
|
-
candidate = (project / rel_path).resolve()
|
|
1291
|
-
try:
|
|
1292
|
-
candidate.relative_to(project.resolve())
|
|
1293
|
-
except ValueError:
|
|
1294
|
-
return None
|
|
1295
|
-
return candidate
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
def validator_result(
|
|
1299
|
-
validator: dict[str, Any],
|
|
1300
|
-
status: str,
|
|
1301
|
-
message: str,
|
|
1302
|
-
) -> dict[str, Any]:
|
|
1303
|
-
result = {
|
|
1304
|
-
"type": str(validator.get("type") or "unknown"),
|
|
1305
|
-
"status": status,
|
|
1306
|
-
"message": message,
|
|
1307
|
-
}
|
|
1308
|
-
description = validator.get("description")
|
|
1309
|
-
if description:
|
|
1310
|
-
result["description"] = str(description)
|
|
1311
|
-
for key in ["path", "pattern", "symbol", "command"]:
|
|
1312
|
-
if key in validator:
|
|
1313
|
-
result[key] = validator[key]
|
|
1314
|
-
return result
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
def read_project_text(project: Path, rel_path: str) -> tuple[Path | None, str | None, str | None]:
|
|
1318
|
-
path = safe_project_path(project, rel_path)
|
|
1319
|
-
if path is None:
|
|
1320
|
-
return None, None, f"path escapes project root: {rel_path}"
|
|
1321
|
-
if not path.exists():
|
|
1322
|
-
return path, None, f"file missing: {rel_path}"
|
|
1323
|
-
if not path.is_file():
|
|
1324
|
-
return path, None, f"path is not a file: {rel_path}"
|
|
1325
|
-
try:
|
|
1326
|
-
return path, path.read_text(encoding="utf-8", errors="ignore"), None
|
|
1327
|
-
except OSError as exc:
|
|
1328
|
-
return path, None, f"file could not be read: {exc}"
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
def evaluate_file_exists(project: Path, validator: dict[str, Any]) -> dict[str, Any]:
|
|
1332
|
-
rel_path = str(validator.get("path") or "")
|
|
1333
|
-
path = safe_project_path(project, rel_path)
|
|
1334
|
-
if path is None:
|
|
1335
|
-
return validator_result(validator, "failed", f"path escapes project root: {rel_path}")
|
|
1336
|
-
if path.exists():
|
|
1337
|
-
return validator_result(validator, "passed", f"file exists: {rel_path}")
|
|
1338
|
-
return validator_result(validator, "failed", f"file missing: {rel_path}")
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
def evaluate_file_contains(project: Path, validator: dict[str, Any]) -> dict[str, Any]:
|
|
1342
|
-
rel_path = str(validator.get("path") or "")
|
|
1343
|
-
pattern = str(validator.get("pattern") or "")
|
|
1344
|
-
if not pattern:
|
|
1345
|
-
return validator_result(validator, "failed", "file_contains validator requires pattern")
|
|
1346
|
-
_, text, error = read_project_text(project, rel_path)
|
|
1347
|
-
if error is not None:
|
|
1348
|
-
return validator_result(validator, "failed", error)
|
|
1349
|
-
if pattern in (text or ""):
|
|
1350
|
-
return validator_result(validator, "passed", f"file contains pattern: {rel_path}")
|
|
1351
|
-
return validator_result(validator, "failed", f"pattern not found in file: {rel_path}")
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
def evaluate_symbol_exists(project: Path, entry: dict[str, Any], validator: dict[str, Any]) -> dict[str, Any]:
|
|
1355
|
-
symbol = str(validator.get("symbol") or "")
|
|
1356
|
-
if not symbol:
|
|
1357
|
-
return validator_result(validator, "failed", "symbol_exists validator requires symbol")
|
|
1358
|
-
raw_files = validator.get("files")
|
|
1359
|
-
files = raw_files if isinstance(raw_files, list) else entry.get("scope", {}).get("sourceFiles") or []
|
|
1360
|
-
files = [str(path) for path in files if path]
|
|
1361
|
-
if not files:
|
|
1362
|
-
return validator_result(validator, "failed", "symbol_exists validator requires files or sourceFiles")
|
|
1363
|
-
checked: list[str] = []
|
|
1364
|
-
missing: list[str] = []
|
|
1365
|
-
for rel_path in files:
|
|
1366
|
-
checked.append(rel_path)
|
|
1367
|
-
_, text, error = read_project_text(project, rel_path)
|
|
1368
|
-
if error is not None:
|
|
1369
|
-
missing.append(error)
|
|
1370
|
-
continue
|
|
1371
|
-
if symbol in (text or ""):
|
|
1372
|
-
return validator_result(validator, "passed", f"symbol found in {rel_path}")
|
|
1373
|
-
detail = "; ".join(missing[:3]) if missing else "symbol not found"
|
|
1374
|
-
return validator_result(validator, "failed", f"{detail}; checked: {', '.join(checked[:8])}")
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
def evaluate_command_validator(project: Path, validator: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
|
1378
|
-
if not config["allowCommandValidators"]:
|
|
1379
|
-
return validator_result(validator, "skipped", "command validators disabled by knowledgeValidation.allowCommandValidators")
|
|
1380
|
-
command = validator.get("command")
|
|
1381
|
-
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
|
1382
|
-
return validator_result(validator, "failed", "command validator requires command as a string array")
|
|
1383
|
-
timeout = max(1, int(config.get("commandTimeoutSeconds") or 60))
|
|
1384
|
-
try:
|
|
1385
|
-
completed = subprocess.run(
|
|
1386
|
-
command,
|
|
1387
|
-
cwd=str(project),
|
|
1388
|
-
text=True,
|
|
1389
|
-
encoding="utf-8",
|
|
1390
|
-
stdout=subprocess.PIPE,
|
|
1391
|
-
stderr=subprocess.PIPE,
|
|
1392
|
-
timeout=timeout,
|
|
1393
|
-
)
|
|
1394
|
-
except subprocess.TimeoutExpired:
|
|
1395
|
-
return validator_result(validator, "failed", f"command timed out after {timeout}s")
|
|
1396
|
-
except OSError as exc:
|
|
1397
|
-
return validator_result(validator, "failed", f"command could not be started: {exc}")
|
|
1398
|
-
if completed.returncode == 0:
|
|
1399
|
-
return validator_result(validator, "passed", "command exited 0")
|
|
1400
|
-
detail = first_sentence((completed.stderr or completed.stdout or "").strip())
|
|
1401
|
-
return validator_result(validator, "failed", f"command exited {completed.returncode}: {detail}")
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
def evaluate_validator(
|
|
1405
|
-
project: Path,
|
|
1406
|
-
entry: dict[str, Any],
|
|
1407
|
-
validator: dict[str, Any],
|
|
1408
|
-
config: dict[str, Any],
|
|
1409
|
-
) -> dict[str, Any]:
|
|
1410
|
-
validator_type = str(validator.get("type") or "")
|
|
1411
|
-
if validator_type == "file_exists":
|
|
1412
|
-
return evaluate_file_exists(project, validator)
|
|
1413
|
-
if validator_type == "file_contains":
|
|
1414
|
-
return evaluate_file_contains(project, validator)
|
|
1415
|
-
if validator_type == "symbol_exists":
|
|
1416
|
-
return evaluate_symbol_exists(project, entry, validator)
|
|
1417
|
-
if validator_type == "command":
|
|
1418
|
-
return evaluate_command_validator(project, validator, config)
|
|
1419
|
-
return validator_result(validator, "failed", f"unknown validator type: {validator_type or 'missing'}")
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
def evaluate_entry_validators(
|
|
1423
|
-
project: Path,
|
|
1424
|
-
entry: dict[str, Any],
|
|
1425
|
-
config: dict[str, Any],
|
|
1426
|
-
) -> dict[str, Any] | None:
|
|
1427
|
-
validators = entry.get("validators")
|
|
1428
|
-
if not isinstance(validators, list) or not validators:
|
|
1429
|
-
return None
|
|
1430
|
-
results = [
|
|
1431
|
-
evaluate_validator(project, entry, validator, config)
|
|
1432
|
-
if isinstance(validator, dict)
|
|
1433
|
-
else {"type": "invalid", "status": "failed", "message": "validator must be an object"}
|
|
1434
|
-
for validator in validators
|
|
1435
|
-
]
|
|
1436
|
-
statuses = [str(result.get("status")) for result in results]
|
|
1437
|
-
if "failed" in statuses:
|
|
1438
|
-
status = "failed"
|
|
1439
|
-
elif "passed" in statuses:
|
|
1440
|
-
status = "passed"
|
|
1441
|
-
else:
|
|
1442
|
-
status = "skipped"
|
|
1443
|
-
validation = {
|
|
1444
|
-
"validatedAt": now_iso(),
|
|
1445
|
-
"status": status,
|
|
1446
|
-
"results": results,
|
|
1447
|
-
}
|
|
1448
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1449
|
-
lifecycle["validation"] = validation
|
|
1450
|
-
lifecycle["lastCheckedAt"] = validation["validatedAt"]
|
|
1451
|
-
return validation
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
def first_failed_validator_message(validation: dict[str, Any]) -> str:
|
|
1455
|
-
for result in validation.get("results") or []:
|
|
1456
|
-
if isinstance(result, dict) and result.get("status") == "failed":
|
|
1457
|
-
description = result.get("description")
|
|
1458
|
-
message = result.get("message")
|
|
1459
|
-
if description and message:
|
|
1460
|
-
return f"{description}: {message}"
|
|
1461
|
-
return str(message or description or "validator failed")
|
|
1462
|
-
return "validator failed"
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
def apply_knowledge_validation(
|
|
1466
|
-
project: Path,
|
|
1467
|
-
knowledge: Path,
|
|
1468
|
-
config: dict[str, Any],
|
|
1469
|
-
) -> dict[str, Any]:
|
|
1470
|
-
policy = knowledge_validation_config(config)
|
|
1471
|
-
summary: dict[str, Any] = {
|
|
1472
|
-
"enabled": policy["enabled"],
|
|
1473
|
-
"checked": 0,
|
|
1474
|
-
"passed": 0,
|
|
1475
|
-
"failed": 0,
|
|
1476
|
-
"skipped": 0,
|
|
1477
|
-
"autoDemoted": 0,
|
|
1478
|
-
"entries": [],
|
|
1479
|
-
}
|
|
1480
|
-
if not policy["enabled"]:
|
|
1481
|
-
return summary
|
|
1482
|
-
|
|
1483
|
-
for source_path, entry in load_entry_files(knowledge):
|
|
1484
|
-
validation = evaluate_entry_validators(project, entry, policy)
|
|
1485
|
-
if validation is None:
|
|
1486
|
-
continue
|
|
1487
|
-
summary["checked"] += 1
|
|
1488
|
-
summary[validation["status"]] += 1
|
|
1489
|
-
entry_summary = {
|
|
1490
|
-
"id": entry["id"],
|
|
1491
|
-
"status": entry.get("status"),
|
|
1492
|
-
"validationStatus": validation["status"],
|
|
1493
|
-
"results": validation["results"],
|
|
1494
|
-
}
|
|
1495
|
-
if validation["status"] == "failed" and entry.get("status") == "active" and policy["autoDemoteActive"]:
|
|
1496
|
-
target_status = policy["defaultTargetStatus"]
|
|
1497
|
-
reason = "validator failed: " + first_failed_validator_message(validation)
|
|
1498
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1499
|
-
lifecycle["previousStatus"] = str(entry.get("status") or "active")
|
|
1500
|
-
entry["status"] = target_status
|
|
1501
|
-
lifecycle["demotedAt"] = now_iso()
|
|
1502
|
-
lifecycle["demotionReason"] = reason
|
|
1503
|
-
lifecycle["autoDemoted"] = True
|
|
1504
|
-
stale_reasons = lifecycle.setdefault("staleReasons", [])
|
|
1505
|
-
if reason not in stale_reasons:
|
|
1506
|
-
stale_reasons.append(reason)
|
|
1507
|
-
target_path = knowledge / "entries" / target_status / entry_filename(entry)
|
|
1508
|
-
write_json(target_path, entry)
|
|
1509
|
-
if source_path != target_path and source_path.exists():
|
|
1510
|
-
source_path.unlink()
|
|
1511
|
-
summary["autoDemoted"] += 1
|
|
1512
|
-
entry_summary["status"] = target_status
|
|
1513
|
-
entry_summary["autoDemoted"] = True
|
|
1514
|
-
else:
|
|
1515
|
-
write_json(source_path, entry)
|
|
1516
|
-
summary["entries"].append(entry_summary)
|
|
1517
|
-
return summary
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
def supersede_overlapping_generated_entries(entries: list[dict[str, Any]]) -> None:
|
|
1521
|
-
comparable_types = {"requirement", "decision", "implementation", "api-contract"}
|
|
1522
|
-
ordered = sorted(entries, key=archive_sort_key)
|
|
1523
|
-
for idx, older in enumerate(ordered):
|
|
1524
|
-
if older.get("status") in {"active", "conflicted"} or older.get("type") not in comparable_types:
|
|
1525
|
-
continue
|
|
1526
|
-
older_files = set(older.get("scope", {}).get("sourceFiles") or [])
|
|
1527
|
-
if not older_files:
|
|
1528
|
-
continue
|
|
1529
|
-
for newer in ordered[idx + 1 :]:
|
|
1530
|
-
if newer.get("status") == "conflicted":
|
|
1531
|
-
continue
|
|
1532
|
-
if newer.get("type") not in comparable_types:
|
|
1533
|
-
continue
|
|
1534
|
-
if archive_sort_key(newer) <= archive_sort_key(older):
|
|
1535
|
-
continue
|
|
1536
|
-
newer_files = set(newer.get("scope", {}).get("sourceFiles") or [])
|
|
1537
|
-
overlap = sorted(older_files & newer_files)
|
|
1538
|
-
if not overlap:
|
|
1539
|
-
continue
|
|
1540
|
-
older["status"] = "superseded"
|
|
1541
|
-
lifecycle = older.setdefault("lifecycle", {})
|
|
1542
|
-
lifecycle["supersededBy"] = newer["id"]
|
|
1543
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
1544
|
-
reason = "overlapped by newer archive: " + newer.get("source", {}).get("archive", "")
|
|
1545
|
-
if reason not in reasons:
|
|
1546
|
-
reasons.append(reason)
|
|
1547
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1548
|
-
break
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
def ttl_days_from_config(config: dict[str, Any]) -> int | None:
|
|
1552
|
-
raw = config.get("staleTtlDays")
|
|
1553
|
-
if raw is None:
|
|
1554
|
-
return None
|
|
1555
|
-
try:
|
|
1556
|
-
ttl_days = int(raw)
|
|
1557
|
-
except (TypeError, ValueError):
|
|
1558
|
-
return None
|
|
1559
|
-
return ttl_days if ttl_days > 0 else None
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
def apply_ttl_stale(entries: list[dict[str, Any]], config: dict[str, Any]) -> None:
|
|
1563
|
-
ttl_days = ttl_days_from_config(config)
|
|
1564
|
-
if ttl_days is None:
|
|
1565
|
-
return
|
|
1566
|
-
|
|
1567
|
-
today = dt.date.today()
|
|
1568
|
-
for entry in entries:
|
|
1569
|
-
if entry.get("status") in {"active", "superseded", "conflicted"}:
|
|
1570
|
-
continue
|
|
1571
|
-
archived_on = archive_date(entry)
|
|
1572
|
-
if archived_on is None:
|
|
1573
|
-
continue
|
|
1574
|
-
age_days = (today - archived_on).days
|
|
1575
|
-
if age_days <= ttl_days:
|
|
1576
|
-
continue
|
|
1577
|
-
|
|
1578
|
-
entry["status"] = "stale"
|
|
1579
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1580
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
1581
|
-
reason = f"ttl expired: archive age {age_days} days exceeds staleTtlDays {ttl_days}"
|
|
1582
|
-
if reason not in reasons:
|
|
1583
|
-
reasons.append(reason)
|
|
1584
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
|
|
1588
|
-
project = project.resolve()
|
|
1589
|
-
harness = project / ".harness"
|
|
1590
|
-
archive_root = harness / "archive"
|
|
1591
|
-
knowledge = harness / "knowledge"
|
|
1592
|
-
pname = project_id(project)
|
|
1593
|
-
config = load_config(knowledge)
|
|
1594
|
-
current_head = git_head(project)
|
|
1595
|
-
reset_generated_knowledge(knowledge)
|
|
1596
|
-
|
|
1597
|
-
summary_paths = sorted(archive_root.glob("*/reports/final/summary-data.json"))
|
|
1598
|
-
entries: list[dict[str, Any]] = []
|
|
1599
|
-
failures: list[dict[str, str]] = []
|
|
1600
|
-
ingest_mode: dict[str, Any] = {
|
|
1601
|
-
"incremental": incremental,
|
|
1602
|
-
"archivesExtracted": 0,
|
|
1603
|
-
"archivesReused": 0,
|
|
1604
|
-
"cacheWrites": 0,
|
|
1605
|
-
"activeAutoDemoted": 0,
|
|
1606
|
-
"confidenceScored": 0,
|
|
1607
|
-
"candidateAutoPromoted": 0,
|
|
1608
|
-
"validationChecked": 0,
|
|
1609
|
-
"validationFailed": 0,
|
|
1610
|
-
"validationAutoDemoted": 0,
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
for summary_path in summary_paths:
|
|
1614
|
-
summary_rel = rel_to_project(project, summary_path)
|
|
1615
|
-
summary_hash = sha256_file(summary_path)
|
|
1616
|
-
cache_path = archive_entry_cache_path(project, knowledge, summary_path, summary_hash)
|
|
1617
|
-
try:
|
|
1618
|
-
archive_entries = None
|
|
1619
|
-
if incremental:
|
|
1620
|
-
archive_entries = load_cached_archive_entries(
|
|
1621
|
-
cache_path,
|
|
1622
|
-
summary_path=summary_rel,
|
|
1623
|
-
summary_hash=summary_hash,
|
|
1624
|
-
head_commit=current_head,
|
|
1625
|
-
)
|
|
1626
|
-
if archive_entries is None:
|
|
1627
|
-
archive_entries = extract_entries(project, pname, summary_path)
|
|
1628
|
-
ingest_mode["archivesExtracted"] += 1
|
|
1629
|
-
if incremental:
|
|
1630
|
-
write_cached_archive_entries(
|
|
1631
|
-
cache_path,
|
|
1632
|
-
summary_path=summary_rel,
|
|
1633
|
-
summary_hash=summary_hash,
|
|
1634
|
-
head_commit=current_head,
|
|
1635
|
-
entries=archive_entries,
|
|
1636
|
-
)
|
|
1637
|
-
ingest_mode["cacheWrites"] += 1
|
|
1638
|
-
else:
|
|
1639
|
-
ingest_mode["archivesReused"] += 1
|
|
1640
|
-
entries.extend(archive_entries)
|
|
1641
|
-
except Exception as exc: # keep one bad archive from blocking the index
|
|
1642
|
-
failures.append({"path": rel_to_project(project, summary_path), "error": str(exc)})
|
|
1643
|
-
|
|
1644
|
-
seen: set[str] = set()
|
|
1645
|
-
deduped: list[dict[str, Any]] = []
|
|
1646
|
-
duplicates = 0
|
|
1647
|
-
for entry in entries:
|
|
1648
|
-
fingerprint = (
|
|
1649
|
-
entry["type"],
|
|
1650
|
-
entry["title"],
|
|
1651
|
-
entry["body"],
|
|
1652
|
-
entry["source"]["archive"],
|
|
1653
|
-
)
|
|
1654
|
-
fp = json.dumps(fingerprint, ensure_ascii=False, sort_keys=True)
|
|
1655
|
-
if fp in seen:
|
|
1656
|
-
duplicates += 1
|
|
1657
|
-
continue
|
|
1658
|
-
seen.add(fp)
|
|
1659
|
-
deduped.append(entry)
|
|
1660
|
-
|
|
1661
|
-
near_dedupe = dedupe_near_duplicates(deduped)
|
|
1662
|
-
ingest_mode["nearDuplicatesMerged"] = near_dedupe["merged"]
|
|
1663
|
-
|
|
1664
|
-
mark_conflicting_generated_entries(deduped)
|
|
1665
|
-
mark_degraded_test_evidence(deduped)
|
|
1666
|
-
supersede_overlapping_generated_entries(deduped)
|
|
1667
|
-
apply_ttl_stale(deduped, config)
|
|
1668
|
-
apply_confidence_scores(deduped, config)
|
|
1669
|
-
auto_promotions = apply_auto_promote_policy(deduped, config)
|
|
1670
|
-
ingest_mode["confidenceScored"] = len(deduped)
|
|
1671
|
-
ingest_mode["candidateAutoPromoted"] = len(auto_promotions)
|
|
1672
|
-
|
|
1673
|
-
preserved_ids = {entry["id"] for entry in load_preserved_entries(knowledge)}
|
|
1674
|
-
for entry in deduped:
|
|
1675
|
-
if entry["id"] in preserved_ids:
|
|
1676
|
-
continue
|
|
1677
|
-
status = entry["status"]
|
|
1678
|
-
filename = entry_filename(entry)
|
|
1679
|
-
target = knowledge / "entries" / status / filename
|
|
1680
|
-
if target.exists():
|
|
1681
|
-
try:
|
|
1682
|
-
existing = read_json(target)
|
|
1683
|
-
except (OSError, json.JSONDecodeError):
|
|
1684
|
-
existing = None
|
|
1685
|
-
if isinstance(existing, dict) and existing.get("id") != entry["id"]:
|
|
1686
|
-
failures.append({"id": entry["id"], "reason": "filename collision",
|
|
1687
|
-
"path": str(target), "conflictsWith": existing.get("id")})
|
|
1688
|
-
continue
|
|
1689
|
-
write_json(target, entry)
|
|
1690
|
-
|
|
1691
|
-
indexed_entries = combine_generated_with_preserved(knowledge, deduped)
|
|
1692
|
-
apply_confidence_scores(indexed_entries, config)
|
|
1693
|
-
persist_entry_updates(knowledge, indexed_entries)
|
|
1694
|
-
auto_demotions = apply_active_lifecycle_policy(knowledge, indexed_entries, config)
|
|
1695
|
-
ingest_mode["activeAutoDemoted"] = len(auto_demotions)
|
|
1696
|
-
validation = apply_knowledge_validation(project, knowledge, config)
|
|
1697
|
-
ingest_mode["validationChecked"] = validation["checked"]
|
|
1698
|
-
ingest_mode["validationFailed"] = validation["failed"]
|
|
1699
|
-
ingest_mode["validationAutoDemoted"] = validation["autoDemoted"]
|
|
1700
|
-
if auto_demotions:
|
|
1701
|
-
indexed_entries = combine_generated_with_preserved(knowledge, deduped)
|
|
1702
|
-
if validation["checked"]:
|
|
1703
|
-
indexed_entries = [entry for _, entry in load_entry_files(knowledge)]
|
|
1704
|
-
apply_confidence_scores(indexed_entries, config)
|
|
1705
|
-
persist_entry_updates(knowledge, indexed_entries)
|
|
1706
|
-
ingest_mode["confidenceScored"] = len(indexed_entries)
|
|
1707
|
-
archive_records = archive_summary_records(project, summary_paths)
|
|
1708
|
-
write_sqlite(knowledge / "index.sqlite", indexed_entries)
|
|
1709
|
-
index = make_manifest(
|
|
1710
|
-
project,
|
|
1711
|
-
pname,
|
|
1712
|
-
summary_paths,
|
|
1713
|
-
archive_records,
|
|
1714
|
-
indexed_entries,
|
|
1715
|
-
failures,
|
|
1716
|
-
duplicates,
|
|
1717
|
-
ingest_mode,
|
|
1718
|
-
)
|
|
1719
|
-
write_json(knowledge / "index.json", index)
|
|
1720
|
-
write_views(knowledge, index, indexed_entries)
|
|
1721
|
-
write_ingest_report(knowledge, index, failures, duplicates)
|
|
1722
|
-
return index
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
def find_entry_file(knowledge: Path, entry_id: str, statuses: list[str]) -> tuple[Path, dict[str, Any]] | None:
|
|
1726
|
-
for status in statuses:
|
|
1727
|
-
for path in sorted((knowledge / "entries" / status).glob("*.json")):
|
|
1728
|
-
try:
|
|
1729
|
-
entry = read_json(path)
|
|
1730
|
-
except (OSError, json.JSONDecodeError):
|
|
1731
|
-
continue
|
|
1732
|
-
if isinstance(entry, dict) and entry.get("id") == entry_id:
|
|
1733
|
-
return path, entry
|
|
1734
|
-
return None
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
def promote_entry(project: Path, entry_id: str, note: str, allow_stale: bool = False) -> dict[str, Any]:
|
|
1738
|
-
project = project.resolve()
|
|
1739
|
-
knowledge = project / ".harness" / "knowledge"
|
|
1740
|
-
if not (knowledge / "index.json").exists():
|
|
1741
|
-
build_index(project)
|
|
1742
|
-
|
|
1743
|
-
statuses = ["candidate"]
|
|
1744
|
-
if allow_stale:
|
|
1745
|
-
statuses.append("stale")
|
|
1746
|
-
found = find_entry_file(knowledge, entry_id, statuses)
|
|
1747
|
-
if found is None:
|
|
1748
|
-
stale_found = find_entry_file(knowledge, entry_id, ["stale"])
|
|
1749
|
-
if stale_found is not None and not allow_stale:
|
|
1750
|
-
raise ValueError("entry is stale; rerun with --allow-stale only after manual verification")
|
|
1751
|
-
raise ValueError(f"entry not found in promotable statuses: {entry_id}")
|
|
1752
|
-
|
|
1753
|
-
source_path, entry = found
|
|
1754
|
-
entry["status"] = "active"
|
|
1755
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1756
|
-
lifecycle["promotedAt"] = now_iso()
|
|
1757
|
-
lifecycle["promotionNote"] = note
|
|
1758
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1759
|
-
active_path = knowledge / "entries" / "active" / entry_filename(entry)
|
|
1760
|
-
write_json(active_path, entry)
|
|
1761
|
-
if source_path != active_path and source_path.exists():
|
|
1762
|
-
source_path.unlink()
|
|
1763
|
-
index = build_index(project)
|
|
1764
|
-
return {
|
|
1765
|
-
"id": entry["id"],
|
|
1766
|
-
"status": "active",
|
|
1767
|
-
"activePath": str(active_path),
|
|
1768
|
-
"index": str(knowledge / "index.json"),
|
|
1769
|
-
"stats": index["stats"],
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
def demote_entry(project: Path, entry_id: str, target_status: str, reason: str) -> dict[str, Any]:
|
|
1774
|
-
if target_status not in {"candidate", "stale"}:
|
|
1775
|
-
raise ValueError("demote target status must be candidate or stale")
|
|
1776
|
-
project = project.resolve()
|
|
1777
|
-
knowledge = project / ".harness" / "knowledge"
|
|
1778
|
-
if not (knowledge / "index.json").exists():
|
|
1779
|
-
build_index(project)
|
|
1780
|
-
|
|
1781
|
-
found = find_entry_file(knowledge, entry_id, ["active"])
|
|
1782
|
-
if found is None:
|
|
1783
|
-
raise ValueError(f"active entry not found: {entry_id}")
|
|
1784
|
-
|
|
1785
|
-
source_path, entry = found
|
|
1786
|
-
previous = str(entry.get("status") or "active")
|
|
1787
|
-
entry["status"] = target_status
|
|
1788
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
1789
|
-
lifecycle["previousStatus"] = previous
|
|
1790
|
-
lifecycle["demotedAt"] = now_iso()
|
|
1791
|
-
lifecycle["demotionReason"] = reason
|
|
1792
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
1793
|
-
if target_status == "stale":
|
|
1794
|
-
stale_reasons = lifecycle.setdefault("staleReasons", [])
|
|
1795
|
-
stale_reason = "manual demotion: " + reason
|
|
1796
|
-
if stale_reason not in stale_reasons:
|
|
1797
|
-
stale_reasons.append(stale_reason)
|
|
1798
|
-
|
|
1799
|
-
target_path = knowledge / "entries" / target_status / entry_filename(entry)
|
|
1800
|
-
write_json(target_path, entry)
|
|
1801
|
-
if source_path != target_path and source_path.exists():
|
|
1802
|
-
source_path.unlink()
|
|
1803
|
-
index = build_index(project)
|
|
1804
|
-
return {
|
|
1805
|
-
"id": entry["id"],
|
|
1806
|
-
"status": target_status,
|
|
1807
|
-
"path": str(target_path),
|
|
1808
|
-
"index": str(knowledge / "index.json"),
|
|
1809
|
-
"stats": index["stats"],
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
def read_sqlite_entries(sqlite_path: Path) -> list[dict[str, Any]]:
|
|
1814
|
-
con = sqlite3.connect(sqlite_path)
|
|
1815
|
-
try:
|
|
1816
|
-
rows = con.execute("select entry_json from entries").fetchall()
|
|
1817
|
-
return [json.loads(row[0]) for row in rows]
|
|
1818
|
-
finally:
|
|
1819
|
-
con.close()
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
def load_indexed_entries(project: Path) -> list[dict[str, Any]]:
|
|
1823
|
-
project = project.resolve()
|
|
1824
|
-
sqlite_path = project / ".harness" / "knowledge" / "index.sqlite"
|
|
1825
|
-
if not sqlite_path.exists():
|
|
1826
|
-
build_index(project)
|
|
1827
|
-
return read_sqlite_entries(sqlite_path)
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
def audit_entries(project: Path, limit: int = 10) -> dict[str, Any]:
|
|
1831
|
-
project = project.resolve()
|
|
1832
|
-
knowledge = project / ".harness" / "knowledge"
|
|
1833
|
-
sync = sync_status(project)
|
|
1834
|
-
if not sync["upToDate"]:
|
|
1835
|
-
build_index(project)
|
|
1836
|
-
entries = load_indexed_entries(project)
|
|
1837
|
-
candidates = sorted(
|
|
1838
|
-
[entry for entry in entries if entry["status"] == "candidate"],
|
|
1839
|
-
key=lambda entry: score_entry(entry, " ".join(entry.get("keywords") or [])),
|
|
1840
|
-
reverse=True,
|
|
1841
|
-
)[:limit]
|
|
1842
|
-
stale = sorted(
|
|
1843
|
-
[entry for entry in entries if entry["status"] == "stale"],
|
|
1844
|
-
key=lambda entry: (len(entry.get("lifecycle", {}).get("staleReasons") or []), score_entry(entry, "")),
|
|
1845
|
-
reverse=True,
|
|
1846
|
-
)[:limit]
|
|
1847
|
-
superseded = sorted(
|
|
1848
|
-
[entry for entry in entries if entry["status"] == "superseded"],
|
|
1849
|
-
key=archive_sort_key,
|
|
1850
|
-
reverse=True,
|
|
1851
|
-
)[:limit]
|
|
1852
|
-
conflicted = sorted(
|
|
1853
|
-
[entry for entry in entries if entry["status"] == "conflicted"],
|
|
1854
|
-
key=archive_sort_key,
|
|
1855
|
-
reverse=True,
|
|
1856
|
-
)[:limit]
|
|
1857
|
-
active_review = active_review_items(entries, limit)
|
|
1858
|
-
report_path = knowledge / "reports" / f"audit-report-{timestamp()}.md"
|
|
1859
|
-
lines = [
|
|
1860
|
-
"# Harness Knowledge Audit Report",
|
|
1861
|
-
"",
|
|
1862
|
-
f"- generatedAt: {now_iso()}",
|
|
1863
|
-
f"- project: `{project}`",
|
|
1864
|
-
f"- limit: {limit}",
|
|
1865
|
-
"",
|
|
1866
|
-
"## Candidate Review",
|
|
1867
|
-
"",
|
|
1868
|
-
]
|
|
1869
|
-
if not candidates:
|
|
1870
|
-
lines.append("No candidate entries found.")
|
|
1871
|
-
for entry in candidates:
|
|
1872
|
-
lines.extend(render_audit_entry(entry))
|
|
1873
|
-
lines.extend(["", "## Stale Review", ""])
|
|
1874
|
-
if not stale:
|
|
1875
|
-
lines.append("No stale entries found.")
|
|
1876
|
-
for entry in stale:
|
|
1877
|
-
lines.extend(render_audit_entry(entry, include_reasons=True))
|
|
1878
|
-
lines.extend(["", "## Superseded Review", ""])
|
|
1879
|
-
if not superseded:
|
|
1880
|
-
lines.append("No superseded entries found.")
|
|
1881
|
-
for entry in superseded:
|
|
1882
|
-
lines.extend(render_audit_entry(entry, include_reasons=True))
|
|
1883
|
-
lines.extend(["", "## Conflict Review", ""])
|
|
1884
|
-
if not conflicted:
|
|
1885
|
-
lines.append("No conflicted entries found.")
|
|
1886
|
-
for entry in conflicted:
|
|
1887
|
-
lines.extend(render_audit_entry(entry, include_reasons=True, include_conflicts=True))
|
|
1888
|
-
lines.extend(["", "## Active Review", ""])
|
|
1889
|
-
if not active_review:
|
|
1890
|
-
lines.append("No active entries require manual review.")
|
|
1891
|
-
for entry in active_review:
|
|
1892
|
-
lines.extend(render_audit_entry(entry, include_review=True))
|
|
1893
|
-
write_text(report_path, "\n".join(lines) + "\n")
|
|
1894
|
-
return {
|
|
1895
|
-
"project": str(project),
|
|
1896
|
-
"limit": limit,
|
|
1897
|
-
"report": str(report_path),
|
|
1898
|
-
"candidateReview": [audit_summary(entry) for entry in candidates],
|
|
1899
|
-
"staleReview": [audit_summary(entry) for entry in stale],
|
|
1900
|
-
"supersededReview": [audit_summary(entry) for entry in superseded],
|
|
1901
|
-
"conflictReview": [audit_summary(entry) for entry in conflicted],
|
|
1902
|
-
"activeReview": [audit_summary(entry) for entry in active_review],
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
def audit_summary(entry: dict[str, Any]) -> dict[str, Any]:
|
|
1907
|
-
summary = {
|
|
1908
|
-
"id": entry["id"],
|
|
1909
|
-
"type": entry["type"],
|
|
1910
|
-
"status": entry["status"],
|
|
1911
|
-
"title": entry["title"],
|
|
1912
|
-
"sourceArchive": entry["source"]["archive"],
|
|
1913
|
-
"sourceFiles": entry["scope"]["sourceFiles"],
|
|
1914
|
-
}
|
|
1915
|
-
if entry.get("reviewReasons"):
|
|
1916
|
-
summary["reviewReasons"] = entry["reviewReasons"]
|
|
1917
|
-
return summary
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
def render_audit_entry(
|
|
1921
|
-
entry: dict[str, Any],
|
|
1922
|
-
include_reasons: bool = False,
|
|
1923
|
-
include_conflicts: bool = False,
|
|
1924
|
-
include_review: bool = False,
|
|
1925
|
-
) -> list[str]:
|
|
1926
|
-
lines = [
|
|
1927
|
-
f"- {entry['title']}",
|
|
1928
|
-
f" - id: `{entry['id']}`",
|
|
1929
|
-
f" - type/status: `{entry['type']}` / `{entry['status']}`",
|
|
1930
|
-
f" - source: `{entry['source']['archive']}`",
|
|
1931
|
-
]
|
|
1932
|
-
if entry["scope"]["sourceFiles"]:
|
|
1933
|
-
lines.append(" - files: " + ", ".join(f"`{path}`" for path in entry["scope"]["sourceFiles"][:6]))
|
|
1934
|
-
if include_reasons:
|
|
1935
|
-
for reason in entry.get("lifecycle", {}).get("staleReasons") or []:
|
|
1936
|
-
lines.append(f" - reason: {reason}")
|
|
1937
|
-
if include_conflicts:
|
|
1938
|
-
for conflict_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
|
|
1939
|
-
lines.append(f" - conflictsWith: `{conflict_id}`")
|
|
1940
|
-
if include_review:
|
|
1941
|
-
for reason in entry.get("reviewReasons") or []:
|
|
1942
|
-
lines.append(f" - review: {reason}")
|
|
1943
|
-
return lines
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
def make_manifest(
|
|
1947
|
-
project: Path,
|
|
1948
|
-
pname: str,
|
|
1949
|
-
summary_paths: list[Path],
|
|
1950
|
-
archive_records: list[dict[str, Any]],
|
|
1951
|
-
entries: list[dict[str, Any]],
|
|
1952
|
-
failures: list[dict[str, str]],
|
|
1953
|
-
duplicates: int,
|
|
1954
|
-
ingest_mode: dict[str, Any] | None = None,
|
|
1955
|
-
) -> dict[str, Any]:
|
|
1956
|
-
stats = {status: 0 for status in ["candidate", "active", "stale", "superseded", "deprecated", "conflicted"]}
|
|
1957
|
-
by_type = {entry_type: 0 for entry_type in sorted(ENTRY_TYPES)}
|
|
1958
|
-
for entry in entries:
|
|
1959
|
-
stats[entry["status"]] = stats.get(entry["status"], 0) + 1
|
|
1960
|
-
by_type[entry["type"]] = by_type.get(entry["type"], 0) + 1
|
|
1961
|
-
|
|
1962
|
-
manifest_entries = [
|
|
1963
|
-
{
|
|
1964
|
-
"id": e["id"],
|
|
1965
|
-
"type": e["type"],
|
|
1966
|
-
"status": e["status"],
|
|
1967
|
-
"title": e["title"],
|
|
1968
|
-
"sourceArchive": e["source"]["archive"],
|
|
1969
|
-
"sourceCommit": e["source"]["sourceCommit"],
|
|
1970
|
-
"sourceFiles": e["scope"]["sourceFiles"],
|
|
1971
|
-
"confidence": e.get("confidence", {}),
|
|
1972
|
-
}
|
|
1973
|
-
for e in sorted(entries, key=lambda item: (item["source"]["archive"], item["type"], item["title"]))
|
|
1974
|
-
]
|
|
1975
|
-
|
|
1976
|
-
return {
|
|
1977
|
-
"schemaVersion": 1,
|
|
1978
|
-
"generatedAt": now_iso(),
|
|
1979
|
-
"projectId": pname,
|
|
1980
|
-
"projectRoot": str(project),
|
|
1981
|
-
"headCommit": git_head(project),
|
|
1982
|
-
"archives": {
|
|
1983
|
-
"scanned": len(summary_paths),
|
|
1984
|
-
"indexed": len(summary_paths) - len(failures),
|
|
1985
|
-
"failed": len(failures),
|
|
1986
|
-
"items": archive_records,
|
|
1987
|
-
},
|
|
1988
|
-
"stats": stats,
|
|
1989
|
-
"byType": by_type,
|
|
1990
|
-
"duplicatesSkipped": duplicates,
|
|
1991
|
-
"ingestMode": ingest_mode or {
|
|
1992
|
-
"incremental": False,
|
|
1993
|
-
"archivesExtracted": len(summary_paths) - len(failures),
|
|
1994
|
-
"archivesReused": 0,
|
|
1995
|
-
"cacheWrites": 0,
|
|
1996
|
-
"activeAutoDemoted": 0,
|
|
1997
|
-
"confidenceScored": 0,
|
|
1998
|
-
"candidateAutoPromoted": 0,
|
|
1999
|
-
"validationChecked": 0,
|
|
2000
|
-
"validationFailed": 0,
|
|
2001
|
-
"validationAutoDemoted": 0,
|
|
2002
|
-
},
|
|
2003
|
-
"failures": failures,
|
|
2004
|
-
"entries": manifest_entries,
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
def write_sqlite(path: Path, entries: list[dict[str, Any]]) -> None:
|
|
2009
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2010
|
-
if path.exists():
|
|
2011
|
-
path.unlink()
|
|
2012
|
-
con = sqlite3.connect(path)
|
|
2013
|
-
try:
|
|
2014
|
-
con.execute("pragma journal_mode=wal")
|
|
2015
|
-
con.execute(
|
|
2016
|
-
"""
|
|
2017
|
-
create table entries (
|
|
2018
|
-
id text primary key,
|
|
2019
|
-
project_id text not null,
|
|
2020
|
-
type text not null,
|
|
2021
|
-
status text not null,
|
|
2022
|
-
title text not null,
|
|
2023
|
-
summary text not null,
|
|
2024
|
-
body text not null,
|
|
2025
|
-
source_archive text not null,
|
|
2026
|
-
source_commit text,
|
|
2027
|
-
source_files_json text not null,
|
|
2028
|
-
keywords_json text not null,
|
|
2029
|
-
entry_json text not null
|
|
2030
|
-
)
|
|
2031
|
-
"""
|
|
2032
|
-
)
|
|
2033
|
-
con.execute(
|
|
2034
|
-
"""
|
|
2035
|
-
create table entry_files (
|
|
2036
|
-
entry_id text not null,
|
|
2037
|
-
source_file text not null,
|
|
2038
|
-
primary key (entry_id, source_file),
|
|
2039
|
-
foreign key (entry_id) references entries(id)
|
|
2040
|
-
)
|
|
2041
|
-
"""
|
|
2042
|
-
)
|
|
2043
|
-
con.execute("create virtual table entries_fts using fts5(id, title, summary, body, keywords)")
|
|
2044
|
-
con.execute("create index idx_entries_status on entries(status)")
|
|
2045
|
-
con.execute("create index idx_entries_type on entries(type)")
|
|
2046
|
-
con.execute("create index idx_entries_source_archive on entries(source_archive)")
|
|
2047
|
-
con.execute("create index idx_entry_files_source_file on entry_files(source_file)")
|
|
2048
|
-
for entry in entries:
|
|
2049
|
-
source_files_json = json.dumps(entry["scope"]["sourceFiles"], ensure_ascii=False)
|
|
2050
|
-
keywords_json = json.dumps(entry["keywords"], ensure_ascii=False)
|
|
2051
|
-
entry_json = json.dumps(entry, ensure_ascii=False)
|
|
2052
|
-
con.execute(
|
|
2053
|
-
"""
|
|
2054
|
-
insert into entries (
|
|
2055
|
-
id, project_id, type, status, title, summary, body, source_archive,
|
|
2056
|
-
source_commit, source_files_json, keywords_json, entry_json
|
|
2057
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2058
|
-
""",
|
|
2059
|
-
(
|
|
2060
|
-
entry["id"],
|
|
2061
|
-
entry["projectId"],
|
|
2062
|
-
entry["type"],
|
|
2063
|
-
entry["status"],
|
|
2064
|
-
entry["title"],
|
|
2065
|
-
entry["summary"],
|
|
2066
|
-
entry["body"],
|
|
2067
|
-
entry["source"]["archive"],
|
|
2068
|
-
entry["source"]["sourceCommit"],
|
|
2069
|
-
source_files_json,
|
|
2070
|
-
keywords_json,
|
|
2071
|
-
entry_json,
|
|
2072
|
-
),
|
|
2073
|
-
)
|
|
2074
|
-
con.execute(
|
|
2075
|
-
"insert into entries_fts (id, title, summary, body, keywords) values (?, ?, ?, ?, ?)",
|
|
2076
|
-
(
|
|
2077
|
-
entry["id"],
|
|
2078
|
-
entry["title"],
|
|
2079
|
-
entry["summary"],
|
|
2080
|
-
entry["body"],
|
|
2081
|
-
" ".join(entry["keywords"]),
|
|
2082
|
-
),
|
|
2083
|
-
)
|
|
2084
|
-
for source_file in entry["scope"]["sourceFiles"]:
|
|
2085
|
-
con.execute(
|
|
2086
|
-
"insert or ignore into entry_files (entry_id, source_file) values (?, ?)",
|
|
2087
|
-
(entry["id"], source_file),
|
|
2088
|
-
)
|
|
2089
|
-
con.commit()
|
|
2090
|
-
finally:
|
|
2091
|
-
con.close()
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str, Any]]) -> None:
|
|
2095
|
-
dashboard = [
|
|
2096
|
-
"# Harness Knowledge Dashboard",
|
|
2097
|
-
"",
|
|
2098
|
-
f"- generatedAt: {index['generatedAt']}",
|
|
2099
|
-
f"- projectId: {index['projectId']}",
|
|
2100
|
-
f"- archives: {index['archives']['indexed']}/{index['archives']['scanned']}",
|
|
2101
|
-
f"- entries: {len(entries)}",
|
|
2102
|
-
"",
|
|
2103
|
-
"## Stats",
|
|
2104
|
-
"",
|
|
2105
|
-
"| status | count |",
|
|
2106
|
-
"|---|---:|",
|
|
2107
|
-
]
|
|
2108
|
-
for status, count in index["stats"].items():
|
|
2109
|
-
dashboard.append(f"| {status} | {count} |")
|
|
2110
|
-
dashboard.extend(["", "## Recent Entries", ""])
|
|
2111
|
-
for entry in entries[:50]:
|
|
2112
|
-
dashboard.append(f"- **{entry['type']}** `{entry['status']}` {entry['title']}")
|
|
2113
|
-
dashboard.append(f" - source: `{entry['source']['archive']}`")
|
|
2114
|
-
write_text(knowledge / "views" / "knowledge-dashboard.md", "\n".join(dashboard) + "\n")
|
|
2115
|
-
|
|
2116
|
-
by_file: dict[str, list[dict[str, Any]]] = {}
|
|
2117
|
-
for entry in entries:
|
|
2118
|
-
for source_file in entry["scope"]["sourceFiles"]:
|
|
2119
|
-
by_file.setdefault(source_file, []).append(entry)
|
|
2120
|
-
lines = ["# Harness Knowledge By File", ""]
|
|
2121
|
-
for source_file in sorted(by_file):
|
|
2122
|
-
lines.extend([f"## `{source_file}`", ""])
|
|
2123
|
-
for entry in by_file[source_file]:
|
|
2124
|
-
lines.append(f"- **{entry['type']}** `{entry['status']}` {entry['title']}")
|
|
2125
|
-
lines.append("")
|
|
2126
|
-
write_text(knowledge / "views" / "by-file.md", "\n".join(lines))
|
|
2127
|
-
|
|
2128
|
-
stale = [entry for entry in entries if entry["status"] == "stale"]
|
|
2129
|
-
lines = ["# Harness Stale Knowledge", ""]
|
|
2130
|
-
if not stale:
|
|
2131
|
-
lines.append("No stale knowledge detected in this run.")
|
|
2132
|
-
for entry in stale:
|
|
2133
|
-
lines.append(f"- **{entry['type']}** {entry['title']}")
|
|
2134
|
-
for reason in entry["lifecycle"]["staleReasons"]:
|
|
2135
|
-
lines.append(f" - {reason}")
|
|
2136
|
-
write_text(knowledge / "views" / "stale-items.md", "\n".join(lines) + "\n")
|
|
2137
|
-
|
|
2138
|
-
superseded = [entry for entry in entries if entry["status"] == "superseded"]
|
|
2139
|
-
lines = ["# Harness Superseded Knowledge", ""]
|
|
2140
|
-
if not superseded:
|
|
2141
|
-
lines.append("No superseded knowledge detected in this run.")
|
|
2142
|
-
for entry in superseded:
|
|
2143
|
-
lines.append(f"- **{entry['type']}** {entry['title']}")
|
|
2144
|
-
superseded_by = entry.get("lifecycle", {}).get("supersededBy")
|
|
2145
|
-
if superseded_by:
|
|
2146
|
-
lines.append(f" - supersededBy: `{superseded_by}`")
|
|
2147
|
-
for reason in entry["lifecycle"].get("staleReasons") or []:
|
|
2148
|
-
lines.append(f" - {reason}")
|
|
2149
|
-
write_text(knowledge / "views" / "superseded-items.md", "\n".join(lines) + "\n")
|
|
2150
|
-
|
|
2151
|
-
conflicted = [entry for entry in entries if entry["status"] == "conflicted"]
|
|
2152
|
-
lines = ["# Harness Conflicted Knowledge", ""]
|
|
2153
|
-
if not conflicted:
|
|
2154
|
-
lines.append("No conflicted knowledge detected in this run.")
|
|
2155
|
-
for entry in conflicted:
|
|
2156
|
-
lines.append(f"- **{entry['type']}** {entry['title']}")
|
|
2157
|
-
for conflict_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
|
|
2158
|
-
lines.append(f" - conflictsWith: `{conflict_id}`")
|
|
2159
|
-
for reason in entry["lifecycle"].get("staleReasons") or []:
|
|
2160
|
-
lines.append(f" - {reason}")
|
|
2161
|
-
write_text(knowledge / "views" / "conflicted-items.md", "\n".join(lines) + "\n")
|
|
2162
|
-
|
|
2163
|
-
active_review = active_review_items(entries)
|
|
2164
|
-
lines = ["# Harness Active Review", ""]
|
|
2165
|
-
if not active_review:
|
|
2166
|
-
lines.append("No active entries require manual review.")
|
|
2167
|
-
for entry in active_review:
|
|
2168
|
-
lines.append(f"- **{entry['type']}** {entry['title']}")
|
|
2169
|
-
lines.append(f" - id: `{entry['id']}`")
|
|
2170
|
-
for reason in entry.get("reviewReasons") or []:
|
|
2171
|
-
lines.append(f" - {reason}")
|
|
2172
|
-
write_text(knowledge / "views" / "active-review.md", "\n".join(lines) + "\n")
|
|
2173
|
-
|
|
2174
|
-
base = [
|
|
2175
|
-
'filters:',
|
|
2176
|
-
' and:',
|
|
2177
|
-
' - \'file.inFolder(".harness/knowledge/entries")\'',
|
|
2178
|
-
' - \'file.ext == "json"\'',
|
|
2179
|
-
'',
|
|
2180
|
-
'properties:',
|
|
2181
|
-
' file.name:',
|
|
2182
|
-
' displayName: "Entry File"',
|
|
2183
|
-
' file.folder:',
|
|
2184
|
-
' displayName: "Lifecycle Folder"',
|
|
2185
|
-
' file.mtime:',
|
|
2186
|
-
' displayName: "Modified"',
|
|
2187
|
-
'',
|
|
2188
|
-
'views:',
|
|
2189
|
-
' - type: table',
|
|
2190
|
-
' name: "Lifecycle Table"',
|
|
2191
|
-
' order:',
|
|
2192
|
-
' - file.name',
|
|
2193
|
-
' - file.folder',
|
|
2194
|
-
' - file.mtime',
|
|
2195
|
-
' - type: table',
|
|
2196
|
-
' name: "Needs Review"',
|
|
2197
|
-
' filters:',
|
|
2198
|
-
' or:',
|
|
2199
|
-
' - \'file.inFolder(".harness/knowledge/entries/stale")\'',
|
|
2200
|
-
' - \'file.inFolder(".harness/knowledge/entries/conflicted")\'',
|
|
2201
|
-
' - \'file.inFolder(".harness/knowledge/entries/superseded")\'',
|
|
2202
|
-
' order:',
|
|
2203
|
-
' - file.name',
|
|
2204
|
-
' - file.folder',
|
|
2205
|
-
' - file.mtime',
|
|
2206
|
-
]
|
|
2207
|
-
write_text(knowledge / "views" / "knowledge.base", "\n".join(base) + "\n")
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
def write_ingest_report(
|
|
2211
|
-
knowledge: Path, index: dict[str, Any], failures: list[dict[str, str]], duplicates: int
|
|
2212
|
-
) -> None:
|
|
2213
|
-
lines = [
|
|
2214
|
-
"# Harness Knowledge Ingest Report",
|
|
2215
|
-
"",
|
|
2216
|
-
f"- generatedAt: {index['generatedAt']}",
|
|
2217
|
-
f"- projectId: {index['projectId']}",
|
|
2218
|
-
f"- archives scanned: {index['archives']['scanned']}",
|
|
2219
|
-
f"- archives indexed: {index['archives']['indexed']}",
|
|
2220
|
-
f"- entries: {len(index['entries'])}",
|
|
2221
|
-
f"- duplicates skipped: {duplicates}",
|
|
2222
|
-
f"- incremental: {index.get('ingestMode', {}).get('incremental', False)}",
|
|
2223
|
-
f"- archives extracted: {index.get('ingestMode', {}).get('archivesExtracted', 0)}",
|
|
2224
|
-
f"- archives reused: {index.get('ingestMode', {}).get('archivesReused', 0)}",
|
|
2225
|
-
f"- confidence scored: {index.get('ingestMode', {}).get('confidenceScored', 0)}",
|
|
2226
|
-
f"- candidate auto-promoted: {index.get('ingestMode', {}).get('candidateAutoPromoted', 0)}",
|
|
2227
|
-
f"- active auto-demoted: {index.get('ingestMode', {}).get('activeAutoDemoted', 0)}",
|
|
2228
|
-
f"- validators checked: {index.get('ingestMode', {}).get('validationChecked', 0)}",
|
|
2229
|
-
f"- validators failed: {index.get('ingestMode', {}).get('validationFailed', 0)}",
|
|
2230
|
-
f"- validator auto-demoted: {index.get('ingestMode', {}).get('validationAutoDemoted', 0)}",
|
|
2231
|
-
"",
|
|
2232
|
-
"## Status Counts",
|
|
2233
|
-
"",
|
|
2234
|
-
"| status | count |",
|
|
2235
|
-
"|---|---:|",
|
|
2236
|
-
]
|
|
2237
|
-
for status, count in index["stats"].items():
|
|
2238
|
-
lines.append(f"| {status} | {count} |")
|
|
2239
|
-
if failures:
|
|
2240
|
-
lines.extend(["", "## Failures", ""])
|
|
2241
|
-
for failure in failures:
|
|
2242
|
-
lines.append(f"- `{failure['path']}`: {failure.get('error') or failure.get('reason')}")
|
|
2243
|
-
write_text(knowledge / "reports" / f"ingest-report-{timestamp()}.md", "\n".join(lines) + "\n")
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
def write_verification_report(knowledge: Path, summary: dict[str, Any]) -> Path:
|
|
2247
|
-
lines = [
|
|
2248
|
-
"# Harness Knowledge Verification Report",
|
|
2249
|
-
"",
|
|
2250
|
-
f"- generatedAt: {summary['generatedAt']}",
|
|
2251
|
-
f"- project: {summary['project']}",
|
|
2252
|
-
f"- enabled: {summary['enabled']}",
|
|
2253
|
-
f"- checked: {summary['checked']}",
|
|
2254
|
-
f"- passed: {summary['passed']}",
|
|
2255
|
-
f"- failed: {summary['failed']}",
|
|
2256
|
-
f"- skipped: {summary['skipped']}",
|
|
2257
|
-
f"- auto-demoted: {summary['autoDemoted']}",
|
|
2258
|
-
"",
|
|
2259
|
-
"## Entries",
|
|
2260
|
-
"",
|
|
2261
|
-
]
|
|
2262
|
-
if not summary["entries"]:
|
|
2263
|
-
lines.append("- No entries with validators.")
|
|
2264
|
-
for entry in summary["entries"]:
|
|
2265
|
-
lines.append(f"- `{entry['id']}` `{entry['validationStatus']}` status=`{entry['status']}`")
|
|
2266
|
-
for result in entry.get("results") or []:
|
|
2267
|
-
description = result.get("description") or result.get("type")
|
|
2268
|
-
message = result.get("message") or ""
|
|
2269
|
-
lines.append(f" - {result.get('status')}: {description} - {message}")
|
|
2270
|
-
path = knowledge / "reports" / f"verification-report-{timestamp()}.md"
|
|
2271
|
-
write_text(path, "\n".join(lines) + "\n")
|
|
2272
|
-
return path
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
def refresh_outputs_from_entry_files(
|
|
2276
|
-
project: Path,
|
|
2277
|
-
knowledge: Path,
|
|
2278
|
-
ingest_mode: dict[str, Any] | None = None,
|
|
2279
|
-
) -> dict[str, Any]:
|
|
2280
|
-
summary_paths = sorted((project / ".harness" / "archive").glob("*/reports/final/summary-data.json"))
|
|
2281
|
-
archive_records = archive_summary_records(project, summary_paths)
|
|
2282
|
-
existing: dict[str, Any] = {}
|
|
2283
|
-
index_path = knowledge / "index.json"
|
|
2284
|
-
if index_path.exists():
|
|
2285
|
-
try:
|
|
2286
|
-
loaded = read_json(index_path)
|
|
2287
|
-
if isinstance(loaded, dict):
|
|
2288
|
-
existing = loaded
|
|
2289
|
-
except (OSError, json.JSONDecodeError):
|
|
2290
|
-
existing = {}
|
|
2291
|
-
file_entries = [entry for _, entry in load_entry_files(knowledge)]
|
|
2292
|
-
file_by_id = {entry["id"]: entry for entry in file_entries}
|
|
2293
|
-
sqlite_path = knowledge / "index.sqlite"
|
|
2294
|
-
if sqlite_path.exists():
|
|
2295
|
-
try:
|
|
2296
|
-
sqlite_entries = read_sqlite_entries(sqlite_path)
|
|
2297
|
-
except (OSError, sqlite3.Error, json.JSONDecodeError):
|
|
2298
|
-
sqlite_entries = []
|
|
2299
|
-
else:
|
|
2300
|
-
sqlite_entries = []
|
|
2301
|
-
if sqlite_entries:
|
|
2302
|
-
entries = [file_by_id.get(entry["id"], entry) for entry in sqlite_entries]
|
|
2303
|
-
existing_ids = {entry["id"] for entry in entries}
|
|
2304
|
-
entries.extend(entry for entry in file_entries if entry["id"] not in existing_ids)
|
|
2305
|
-
else:
|
|
2306
|
-
entries = file_entries
|
|
2307
|
-
index = make_manifest(
|
|
2308
|
-
project,
|
|
2309
|
-
project_id(project),
|
|
2310
|
-
summary_paths,
|
|
2311
|
-
archive_records,
|
|
2312
|
-
entries,
|
|
2313
|
-
existing.get("failures") or [],
|
|
2314
|
-
int(existing.get("duplicatesSkipped") or 0),
|
|
2315
|
-
ingest_mode or existing.get("ingestMode") or {},
|
|
2316
|
-
)
|
|
2317
|
-
write_sqlite(knowledge / "index.sqlite", entries)
|
|
2318
|
-
write_json(index_path, index)
|
|
2319
|
-
write_views(knowledge, index, entries)
|
|
2320
|
-
return index
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
def verify_knowledge(project: Path) -> dict[str, Any]:
|
|
2324
|
-
project = project.resolve()
|
|
2325
|
-
knowledge = project / ".harness" / "knowledge"
|
|
2326
|
-
if not (knowledge / "index.json").exists():
|
|
2327
|
-
build_index(project)
|
|
2328
|
-
config = load_config(knowledge)
|
|
2329
|
-
summary = apply_knowledge_validation(project, knowledge, config)
|
|
2330
|
-
summary["generatedAt"] = now_iso()
|
|
2331
|
-
summary["project"] = str(project)
|
|
2332
|
-
report = write_verification_report(knowledge, summary)
|
|
2333
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
2334
|
-
summary["report"] = str(report)
|
|
2335
|
-
summary["paths"] = {
|
|
2336
|
-
"index": str(knowledge / "index.json"),
|
|
2337
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
2338
|
-
"report": str(report),
|
|
2339
|
-
}
|
|
2340
|
-
summary["stats"] = index["stats"]
|
|
2341
|
-
return summary
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
VALIDATOR_TOKEN_STOPWORDS = {
|
|
2345
|
-
"api",
|
|
2346
|
-
"apps",
|
|
2347
|
-
"async",
|
|
2348
|
-
"body",
|
|
2349
|
-
"cache",
|
|
2350
|
-
"candidate",
|
|
2351
|
-
"change",
|
|
2352
|
-
"client",
|
|
2353
|
-
"commit",
|
|
2354
|
-
"config",
|
|
2355
|
-
"data",
|
|
2356
|
-
"entry",
|
|
2357
|
-
"error",
|
|
2358
|
-
"file",
|
|
2359
|
-
"files",
|
|
2360
|
-
"final",
|
|
2361
|
-
"harness",
|
|
2362
|
-
"index",
|
|
2363
|
-
"json",
|
|
2364
|
-
"knowledge",
|
|
2365
|
-
"manual",
|
|
2366
|
-
"project",
|
|
2367
|
-
"result",
|
|
2368
|
-
"source",
|
|
2369
|
-
"status",
|
|
2370
|
-
"store",
|
|
2371
|
-
"summary",
|
|
2372
|
-
"test",
|
|
2373
|
-
"tests",
|
|
2374
|
-
"type",
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
def validator_candidate_tokens(entry: dict[str, Any]) -> list[str]:
|
|
2379
|
-
text = " ".join(
|
|
2380
|
-
str(value or "")
|
|
2381
|
-
for value in [
|
|
2382
|
-
entry.get("title"),
|
|
2383
|
-
entry.get("summary"),
|
|
2384
|
-
entry.get("body"),
|
|
2385
|
-
" ".join(entry.get("keywords") or []),
|
|
2386
|
-
]
|
|
2387
|
-
)
|
|
2388
|
-
tokens = []
|
|
2389
|
-
seen = set()
|
|
2390
|
-
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_.]{3,}", text):
|
|
2391
|
-
lower = token.lower().strip("._")
|
|
2392
|
-
if lower in VALIDATOR_TOKEN_STOPWORDS:
|
|
2393
|
-
continue
|
|
2394
|
-
if token not in seen:
|
|
2395
|
-
seen.add(token)
|
|
2396
|
-
tokens.append(token)
|
|
2397
|
-
tokens.sort(key=lambda token: (("." not in token and not any(c.isupper() for c in token)), len(token)))
|
|
2398
|
-
return tokens[:12]
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
def suggest_validators_for_entry(project: Path, entry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2402
|
-
if isinstance(entry.get("validators"), list) and entry["validators"]:
|
|
2403
|
-
return []
|
|
2404
|
-
source_files = [str(path) for path in entry.get("scope", {}).get("sourceFiles") or [] if path]
|
|
2405
|
-
suggestions: list[dict[str, Any]] = []
|
|
2406
|
-
tokens = validator_candidate_tokens(entry)
|
|
2407
|
-
for rel_path in source_files[:3]:
|
|
2408
|
-
path = safe_project_path(project, rel_path)
|
|
2409
|
-
if path is None or not path.exists() or not path.is_file():
|
|
2410
|
-
continue
|
|
2411
|
-
suggestions.append(
|
|
2412
|
-
{
|
|
2413
|
-
"type": "file_exists",
|
|
2414
|
-
"path": rel_path,
|
|
2415
|
-
"description": f"source file still exists: {rel_path}",
|
|
2416
|
-
}
|
|
2417
|
-
)
|
|
2418
|
-
try:
|
|
2419
|
-
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
2420
|
-
except OSError:
|
|
2421
|
-
continue
|
|
2422
|
-
for token in tokens:
|
|
2423
|
-
if token in text:
|
|
2424
|
-
suggestions.append(
|
|
2425
|
-
{
|
|
2426
|
-
"type": "file_contains",
|
|
2427
|
-
"path": rel_path,
|
|
2428
|
-
"pattern": token,
|
|
2429
|
-
"description": f"source file still contains {token}",
|
|
2430
|
-
}
|
|
2431
|
-
)
|
|
2432
|
-
break
|
|
2433
|
-
return suggestions[:4]
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
def write_validator_suggestions_report(
|
|
2437
|
-
knowledge: Path,
|
|
2438
|
-
summary: dict[str, Any],
|
|
2439
|
-
) -> Path:
|
|
2440
|
-
lines = [
|
|
2441
|
-
"# Harness Validator Suggestions",
|
|
2442
|
-
"",
|
|
2443
|
-
f"- generatedAt: {summary['generatedAt']}",
|
|
2444
|
-
f"- project: {summary['project']}",
|
|
2445
|
-
f"- suggested entries: {summary['suggested']}",
|
|
2446
|
-
f"- applied entries: {summary['applied']}",
|
|
2447
|
-
"",
|
|
2448
|
-
"## Suggestions",
|
|
2449
|
-
"",
|
|
2450
|
-
]
|
|
2451
|
-
if not summary["entries"]:
|
|
2452
|
-
lines.append("- No validator suggestions found.")
|
|
2453
|
-
for item in summary["entries"]:
|
|
2454
|
-
lines.append(f"- `{item['id']}` `{item['status']}` {item['title']}")
|
|
2455
|
-
for validator in item["validators"]:
|
|
2456
|
-
detail = validator.get("pattern") or validator.get("path") or validator.get("type")
|
|
2457
|
-
lines.append(f" - `{validator['type']}` {detail}: {validator.get('description', '')}")
|
|
2458
|
-
path = knowledge / "reports" / f"validator-suggestions-{timestamp()}.md"
|
|
2459
|
-
write_text(path, "\n".join(lines) + "\n")
|
|
2460
|
-
return path
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
def suggest_validators(
|
|
2464
|
-
project: Path,
|
|
2465
|
-
limit: int = 20,
|
|
2466
|
-
statuses: list[str] | None = None,
|
|
2467
|
-
apply: bool = False,
|
|
2468
|
-
) -> dict[str, Any]:
|
|
2469
|
-
project = project.resolve()
|
|
2470
|
-
knowledge = project / ".harness" / "knowledge"
|
|
2471
|
-
if not (knowledge / "index.json").exists():
|
|
2472
|
-
build_index(project)
|
|
2473
|
-
selected_statuses = statuses or ["active", "candidate"]
|
|
2474
|
-
suggestions: list[dict[str, Any]] = []
|
|
2475
|
-
applied = 0
|
|
2476
|
-
for entry_path, entry in load_entry_files(knowledge, selected_statuses):
|
|
2477
|
-
validators = suggest_validators_for_entry(project, entry)
|
|
2478
|
-
if not validators:
|
|
2479
|
-
continue
|
|
2480
|
-
item = {
|
|
2481
|
-
"id": entry["id"],
|
|
2482
|
-
"status": entry.get("status"),
|
|
2483
|
-
"title": entry.get("title"),
|
|
2484
|
-
"path": str(entry_path),
|
|
2485
|
-
"validators": validators,
|
|
2486
|
-
}
|
|
2487
|
-
suggestions.append(item)
|
|
2488
|
-
if apply:
|
|
2489
|
-
entry["validators"] = validators
|
|
2490
|
-
write_json(entry_path, entry)
|
|
2491
|
-
applied += 1
|
|
2492
|
-
if len(suggestions) >= max(1, limit):
|
|
2493
|
-
break
|
|
2494
|
-
summary = {
|
|
2495
|
-
"generatedAt": now_iso(),
|
|
2496
|
-
"project": str(project),
|
|
2497
|
-
"statuses": selected_statuses,
|
|
2498
|
-
"limit": limit,
|
|
2499
|
-
"suggested": len(suggestions),
|
|
2500
|
-
"applied": applied,
|
|
2501
|
-
"entries": suggestions,
|
|
2502
|
-
}
|
|
2503
|
-
report = write_validator_suggestions_report(knowledge, summary)
|
|
2504
|
-
if apply and applied:
|
|
2505
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
2506
|
-
summary["stats"] = index["stats"]
|
|
2507
|
-
summary["report"] = str(report)
|
|
2508
|
-
return summary
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
def auto_knowledge(
|
|
2512
|
-
project: Path,
|
|
2513
|
-
*,
|
|
2514
|
-
limit: int = 20,
|
|
2515
|
-
suggest_statuses: list[str] | None = None,
|
|
2516
|
-
apply_suggestions: bool = False,
|
|
2517
|
-
incremental: bool = True,
|
|
2518
|
-
audit_limit: int = 10,
|
|
2519
|
-
) -> dict[str, Any]:
|
|
2520
|
-
project = project.resolve()
|
|
2521
|
-
knowledge = project / ".harness" / "knowledge"
|
|
2522
|
-
config_summary = ensure_auto_knowledge_config(knowledge)
|
|
2523
|
-
sync = sync_status(project, update=True, incremental=incremental)
|
|
2524
|
-
if config_summary["created"]:
|
|
2525
|
-
if sync.get("action") == "ingested":
|
|
2526
|
-
ingest_mode = sync.get("index", {}).get("ingestMode", {})
|
|
2527
|
-
config_summary["appliedBy"] = "sync"
|
|
2528
|
-
config_summary["candidateAutoPromoted"] = ingest_mode.get("candidateAutoPromoted", 0)
|
|
2529
|
-
elif sync.get("upToDate"):
|
|
2530
|
-
index = build_index(project, incremental=incremental)
|
|
2531
|
-
config_summary["appliedBy"] = "auto-rebuild"
|
|
2532
|
-
config_summary["candidateAutoPromoted"] = index.get("ingestMode", {}).get("candidateAutoPromoted", 0)
|
|
2533
|
-
sync = sync_status(project, update=False, incremental=incremental)
|
|
2534
|
-
suggestions = suggest_validators(
|
|
2535
|
-
project,
|
|
2536
|
-
limit=limit,
|
|
2537
|
-
statuses=suggest_statuses or [],
|
|
2538
|
-
apply=apply_suggestions,
|
|
2539
|
-
)
|
|
2540
|
-
verification = verify_knowledge(project)
|
|
2541
|
-
audit = audit_entries(project, limit=audit_limit)
|
|
2542
|
-
return {
|
|
2543
|
-
"project": str(project),
|
|
2544
|
-
"generatedAt": now_iso(),
|
|
2545
|
-
"mode": {
|
|
2546
|
-
"applySuggestions": apply_suggestions,
|
|
2547
|
-
"suggestStatuses": suggest_statuses or ["active", "candidate"],
|
|
2548
|
-
"limit": limit,
|
|
2549
|
-
"auditLimit": audit_limit,
|
|
2550
|
-
"incremental": incremental,
|
|
2551
|
-
},
|
|
2552
|
-
"config": config_summary,
|
|
2553
|
-
"sync": {
|
|
2554
|
-
"upToDate": sync["upToDate"],
|
|
2555
|
-
"action": sync["action"],
|
|
2556
|
-
"reasons": sync["reasons"],
|
|
2557
|
-
"archiveCount": sync["archiveCount"],
|
|
2558
|
-
"paths": sync["paths"],
|
|
2559
|
-
},
|
|
2560
|
-
"suggestions": {
|
|
2561
|
-
"suggested": suggestions["suggested"],
|
|
2562
|
-
"applied": suggestions["applied"],
|
|
2563
|
-
"report": suggestions["report"],
|
|
2564
|
-
},
|
|
2565
|
-
"verification": {
|
|
2566
|
-
"checked": verification["checked"],
|
|
2567
|
-
"passed": verification["passed"],
|
|
2568
|
-
"failed": verification["failed"],
|
|
2569
|
-
"skipped": verification["skipped"],
|
|
2570
|
-
"autoDemoted": verification["autoDemoted"],
|
|
2571
|
-
"report": verification["report"],
|
|
2572
|
-
},
|
|
2573
|
-
"audit": {
|
|
2574
|
-
"report": audit["report"],
|
|
2575
|
-
"candidateReview": len(audit["candidateReview"]),
|
|
2576
|
-
"staleReview": len(audit["staleReview"]),
|
|
2577
|
-
"supersededReview": len(audit["supersededReview"]),
|
|
2578
|
-
"conflictReview": len(audit["conflictReview"]),
|
|
2579
|
-
"activeReview": len(audit["activeReview"]),
|
|
2580
|
-
},
|
|
2581
|
-
}
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
def fts_query(query: str) -> str:
|
|
2585
|
-
tokens = re.findall(r"[\w\u4e00-\u9fff]{2,}", query, flags=re.UNICODE)
|
|
2586
|
-
if not tokens:
|
|
2587
|
-
return '"' + query.replace('"', '""') + '"'
|
|
2588
|
-
return " OR ".join('"' + token.replace('"', '""') + '"' for token in tokens[:12])
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
def query_index(
|
|
2592
|
-
project: Path,
|
|
2593
|
-
query: str,
|
|
2594
|
-
limit: int = 10,
|
|
2595
|
-
file_filters: list[str] | None = None,
|
|
2596
|
-
statuses: list[str] | None = None,
|
|
2597
|
-
types: list[str] | None = None,
|
|
2598
|
-
) -> dict[str, Any]:
|
|
2599
|
-
project = project.resolve()
|
|
2600
|
-
knowledge = project / ".harness" / "knowledge"
|
|
2601
|
-
sqlite_path = knowledge / "index.sqlite"
|
|
2602
|
-
if not sqlite_path.exists():
|
|
2603
|
-
build_index(project)
|
|
2604
|
-
sync = sync_status(project)
|
|
2605
|
-
if not sync["upToDate"]:
|
|
2606
|
-
build_index(project)
|
|
2607
|
-
entries = search_entries(sqlite_path, query, limit, file_filters, statuses, types)
|
|
2608
|
-
context_path = write_context_pack(project, knowledge, query, entries)
|
|
2609
|
-
filters = {
|
|
2610
|
-
"files": file_filters or [],
|
|
2611
|
-
"statuses": statuses or [],
|
|
2612
|
-
"types": types or [],
|
|
2613
|
-
}
|
|
2614
|
-
return {
|
|
2615
|
-
"query": query,
|
|
2616
|
-
"matchCount": len(entries),
|
|
2617
|
-
"contextPack": str(context_path),
|
|
2618
|
-
"filters": filters,
|
|
2619
|
-
"planInput": {
|
|
2620
|
-
"kind": "harness-knowledge-context-pack",
|
|
2621
|
-
"path": str(context_path),
|
|
2622
|
-
"requiredBefore": "harness-plan",
|
|
2623
|
-
"usage": "Read this context pack before design, planning, code exploration, or implementation.",
|
|
2624
|
-
},
|
|
2625
|
-
"matches": [
|
|
2626
|
-
{
|
|
2627
|
-
"id": entry["id"],
|
|
2628
|
-
"type": entry["type"],
|
|
2629
|
-
"status": entry["status"],
|
|
2630
|
-
"title": entry["title"],
|
|
2631
|
-
"sourceArchive": entry["source"]["archive"],
|
|
2632
|
-
"sourceFiles": entry["scope"]["sourceFiles"],
|
|
2633
|
-
}
|
|
2634
|
-
for entry in entries
|
|
2635
|
-
],
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
def summarize_index(index: dict[str, Any]) -> dict[str, Any]:
|
|
2640
|
-
project_root = Path(index["projectRoot"])
|
|
2641
|
-
knowledge = project_root / ".harness" / "knowledge"
|
|
2642
|
-
return {
|
|
2643
|
-
"projectId": index["projectId"],
|
|
2644
|
-
"generatedAt": index["generatedAt"],
|
|
2645
|
-
"headCommit": index["headCommit"],
|
|
2646
|
-
"archives": index["archives"],
|
|
2647
|
-
"stats": index["stats"],
|
|
2648
|
-
"byType": index["byType"],
|
|
2649
|
-
"duplicatesSkipped": index["duplicatesSkipped"],
|
|
2650
|
-
"ingestMode": index.get("ingestMode", {}),
|
|
2651
|
-
"failures": index["failures"],
|
|
2652
|
-
"paths": {
|
|
2653
|
-
"index": str(knowledge / "index.json"),
|
|
2654
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
2655
|
-
"dashboard": str(knowledge / "views" / "knowledge-dashboard.md"),
|
|
2656
|
-
"byFile": str(knowledge / "views" / "by-file.md"),
|
|
2657
|
-
"staleItems": str(knowledge / "views" / "stale-items.md"),
|
|
2658
|
-
},
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
def sync_status(project: Path, update: bool = False, incremental: bool = True) -> dict[str, Any]:
|
|
2663
|
-
project = project.resolve()
|
|
2664
|
-
knowledge = project / ".harness" / "knowledge"
|
|
2665
|
-
index_path = knowledge / "index.json"
|
|
2666
|
-
sqlite_path = knowledge / "index.sqlite"
|
|
2667
|
-
archive_root = project / ".harness" / "archive"
|
|
2668
|
-
summary_paths = sorted(archive_root.glob("*/reports/final/summary-data.json"))
|
|
2669
|
-
current_records = archive_summary_records(project, summary_paths)
|
|
2670
|
-
reasons: list[str] = []
|
|
2671
|
-
index: dict[str, Any] | None = None
|
|
2672
|
-
|
|
2673
|
-
if not index_path.exists():
|
|
2674
|
-
reasons.append("index.json missing")
|
|
2675
|
-
else:
|
|
2676
|
-
try:
|
|
2677
|
-
index = read_json(index_path)
|
|
2678
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
2679
|
-
reasons.append("index.json unreadable: " + first_sentence(str(exc)))
|
|
2680
|
-
|
|
2681
|
-
if not sqlite_path.exists():
|
|
2682
|
-
reasons.append("index.sqlite missing")
|
|
2683
|
-
|
|
2684
|
-
if index is not None:
|
|
2685
|
-
indexed_records = index.get("archives", {}).get("items")
|
|
2686
|
-
if not isinstance(indexed_records, list):
|
|
2687
|
-
reasons.append("archive checksums missing from index")
|
|
2688
|
-
else:
|
|
2689
|
-
indexed_by_path = {record.get("summaryData"): record for record in indexed_records}
|
|
2690
|
-
current_by_path = {record.get("summaryData"): record for record in current_records}
|
|
2691
|
-
indexed_paths = set(indexed_by_path)
|
|
2692
|
-
current_paths = set(current_by_path)
|
|
2693
|
-
for path in sorted(current_paths - indexed_paths):
|
|
2694
|
-
reasons.append("archive added: " + str(path))
|
|
2695
|
-
for path in sorted(indexed_paths - current_paths):
|
|
2696
|
-
reasons.append("archive removed: " + str(path))
|
|
2697
|
-
for path in sorted(current_paths & indexed_paths):
|
|
2698
|
-
if current_by_path[path].get("summarySha256") != indexed_by_path[path].get("summarySha256"):
|
|
2699
|
-
reasons.append("archive checksum changed: " + str(path))
|
|
2700
|
-
|
|
2701
|
-
current_head = git_head(project)
|
|
2702
|
-
if current_head != index.get("headCommit"):
|
|
2703
|
-
reasons.append("head commit changed since last ingest")
|
|
2704
|
-
|
|
2705
|
-
action = "none"
|
|
2706
|
-
refreshed: dict[str, Any] | None = None
|
|
2707
|
-
if reasons and update:
|
|
2708
|
-
refreshed = build_index(project, incremental=incremental)
|
|
2709
|
-
action = "ingested"
|
|
2710
|
-
reasons = []
|
|
2711
|
-
|
|
2712
|
-
result = {
|
|
2713
|
-
"project": str(project),
|
|
2714
|
-
"upToDate": not reasons,
|
|
2715
|
-
"action": action,
|
|
2716
|
-
"reasons": reasons,
|
|
2717
|
-
"archiveCount": len(current_records),
|
|
2718
|
-
"paths": {
|
|
2719
|
-
"index": str(index_path),
|
|
2720
|
-
"sqlite": str(sqlite_path),
|
|
2721
|
-
},
|
|
2722
|
-
}
|
|
2723
|
-
if refreshed is not None:
|
|
2724
|
-
result["index"] = summarize_index(refreshed)
|
|
2725
|
-
return result
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
def search_entries(
|
|
2729
|
-
sqlite_path: Path,
|
|
2730
|
-
query: str,
|
|
2731
|
-
limit: int,
|
|
2732
|
-
file_filters: list[str] | None = None,
|
|
2733
|
-
statuses: list[str] | None = None,
|
|
2734
|
-
types: list[str] | None = None,
|
|
2735
|
-
) -> list[dict[str, Any]]:
|
|
2736
|
-
con = sqlite3.connect(sqlite_path)
|
|
2737
|
-
try:
|
|
2738
|
-
con.row_factory = sqlite3.Row
|
|
2739
|
-
rows: list[sqlite3.Row] = []
|
|
2740
|
-
fetch_limit = max(limit * 5, 50)
|
|
2741
|
-
try:
|
|
2742
|
-
rows.extend(
|
|
2743
|
-
con.execute(
|
|
2744
|
-
"""
|
|
2745
|
-
select e.entry_json
|
|
2746
|
-
from entries_fts f
|
|
2747
|
-
join entries e on e.id = f.id
|
|
2748
|
-
where entries_fts match ?
|
|
2749
|
-
limit ?
|
|
2750
|
-
""",
|
|
2751
|
-
(fts_query(query), fetch_limit),
|
|
2752
|
-
).fetchall()
|
|
2753
|
-
)
|
|
2754
|
-
except sqlite3.OperationalError:
|
|
2755
|
-
like = f"%{query}%"
|
|
2756
|
-
rows.extend(
|
|
2757
|
-
con.execute(
|
|
2758
|
-
"""
|
|
2759
|
-
select entry_json
|
|
2760
|
-
from entries
|
|
2761
|
-
where title like ? or summary like ? or body like ?
|
|
2762
|
-
limit ?
|
|
2763
|
-
""",
|
|
2764
|
-
(like, like, like, fetch_limit),
|
|
2765
|
-
).fetchall()
|
|
2766
|
-
)
|
|
2767
|
-
|
|
2768
|
-
like_terms = [f"%{token}%" for token in query_tokens(query)]
|
|
2769
|
-
for like in like_terms[:10]:
|
|
2770
|
-
rows.extend(
|
|
2771
|
-
con.execute(
|
|
2772
|
-
"""
|
|
2773
|
-
select entry_json
|
|
2774
|
-
from entries
|
|
2775
|
-
where title like ? or summary like ? or body like ? or keywords_json like ?
|
|
2776
|
-
limit ?
|
|
2777
|
-
""",
|
|
2778
|
-
(like, like, like, like, fetch_limit),
|
|
2779
|
-
).fetchall()
|
|
2780
|
-
)
|
|
2781
|
-
|
|
2782
|
-
for source_file in file_filters or []:
|
|
2783
|
-
rows.extend(
|
|
2784
|
-
con.execute(
|
|
2785
|
-
"""
|
|
2786
|
-
select e.entry_json
|
|
2787
|
-
from entry_files ef
|
|
2788
|
-
join entries e on e.id = ef.entry_id
|
|
2789
|
-
where ef.source_file = ? or ef.source_file like ?
|
|
2790
|
-
limit ?
|
|
2791
|
-
""",
|
|
2792
|
-
(source_file, f"%{source_file}%", fetch_limit),
|
|
2793
|
-
).fetchall()
|
|
2794
|
-
)
|
|
2795
|
-
|
|
2796
|
-
entries_by_id: dict[str, dict[str, Any]] = {}
|
|
2797
|
-
for row in rows:
|
|
2798
|
-
entry = json.loads(row["entry_json"])
|
|
2799
|
-
if not entry_matches_filters(entry, file_filters, statuses, types):
|
|
2800
|
-
continue
|
|
2801
|
-
entries_by_id[entry["id"]] = entry
|
|
2802
|
-
ranked = sorted(
|
|
2803
|
-
entries_by_id.values(),
|
|
2804
|
-
key=lambda entry: score_entry(entry, query),
|
|
2805
|
-
reverse=True,
|
|
2806
|
-
)
|
|
2807
|
-
return ranked[:limit]
|
|
2808
|
-
finally:
|
|
2809
|
-
con.close()
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
def entry_matches_filters(
|
|
2813
|
-
entry: dict[str, Any],
|
|
2814
|
-
file_filters: list[str] | None = None,
|
|
2815
|
-
statuses: list[str] | None = None,
|
|
2816
|
-
types: list[str] | None = None,
|
|
2817
|
-
) -> bool:
|
|
2818
|
-
if statuses and entry.get("status") not in statuses:
|
|
2819
|
-
return False
|
|
2820
|
-
if types and entry.get("type") not in types:
|
|
2821
|
-
return False
|
|
2822
|
-
if file_filters:
|
|
2823
|
-
source_files = entry.get("scope", {}).get("sourceFiles") or []
|
|
2824
|
-
for wanted in file_filters:
|
|
2825
|
-
if not any(wanted == actual or wanted in actual or actual in wanted for actual in source_files):
|
|
2826
|
-
return False
|
|
2827
|
-
return True
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
def query_tokens(query: str) -> list[str]:
|
|
2831
|
-
tokens = re.findall(r"[\w\u4e00-\u9fff]{2,}", query.lower(), flags=re.UNICODE)
|
|
2832
|
-
return list(dict.fromkeys(tokens))
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
def score_entry(entry: dict[str, Any], query: str) -> int:
|
|
2836
|
-
tokens = query_tokens(query)
|
|
2837
|
-
haystack = " ".join(
|
|
2838
|
-
[
|
|
2839
|
-
entry.get("title", ""),
|
|
2840
|
-
entry.get("summary", ""),
|
|
2841
|
-
entry.get("body", ""),
|
|
2842
|
-
" ".join(entry.get("keywords") or []),
|
|
2843
|
-
entry.get("source", {}).get("archive", ""),
|
|
2844
|
-
" ".join(entry.get("scope", {}).get("sourceFiles") or []),
|
|
2845
|
-
]
|
|
2846
|
-
).lower()
|
|
2847
|
-
score = 0
|
|
2848
|
-
q = query.lower().strip()
|
|
2849
|
-
if q and q in haystack:
|
|
2850
|
-
score += 20
|
|
2851
|
-
for token in tokens:
|
|
2852
|
-
if token in haystack:
|
|
2853
|
-
score += 8
|
|
2854
|
-
if token in entry.get("title", "").lower():
|
|
2855
|
-
score += 4
|
|
2856
|
-
score += {
|
|
2857
|
-
"requirement": 12,
|
|
2858
|
-
"decision": 8,
|
|
2859
|
-
"risk": 7,
|
|
2860
|
-
"test-evidence": 5,
|
|
2861
|
-
"api-contract": 4,
|
|
2862
|
-
"implementation": 3,
|
|
2863
|
-
"pitfall": 6,
|
|
2864
|
-
}.get(entry.get("type"), 0)
|
|
2865
|
-
score += {
|
|
2866
|
-
"active": 6,
|
|
2867
|
-
"candidate": 4,
|
|
2868
|
-
"stale": 1,
|
|
2869
|
-
"superseded": -3,
|
|
2870
|
-
"deprecated": -4,
|
|
2871
|
-
"conflicted": -2,
|
|
2872
|
-
}.get(entry.get("status"), 0)
|
|
2873
|
-
archive = entry.get("source", {}).get("archive", "")
|
|
2874
|
-
match = re.search(r"(\d{4}-\d{2}-\d{2})", archive)
|
|
2875
|
-
if match:
|
|
2876
|
-
try:
|
|
2877
|
-
days = (dt.date.today() - dt.date.fromisoformat(match.group(1))).days
|
|
2878
|
-
score += max(0, 10 - days)
|
|
2879
|
-
except ValueError:
|
|
2880
|
-
pass
|
|
2881
|
-
return score
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
def write_context_pack(project: Path, knowledge: Path, query: str, entries: list[dict[str, Any]]) -> Path:
|
|
2885
|
-
filename = f"{timestamp()}-{safe_filename(query) or 'query'}-{short_hash(query, 6)}.md"
|
|
2886
|
-
path = knowledge / "context-packs" / filename
|
|
2887
|
-
active_like = [e for e in entries if e["status"] in {"active", "candidate"}]
|
|
2888
|
-
stale = [e for e in entries if e["status"] == "stale"]
|
|
2889
|
-
risks = [e for e in entries if e["type"] == "risk"]
|
|
2890
|
-
files: list[str] = []
|
|
2891
|
-
for entry in entries:
|
|
2892
|
-
for source_file in entry["scope"]["sourceFiles"]:
|
|
2893
|
-
if source_file not in files:
|
|
2894
|
-
files.append(source_file)
|
|
2895
|
-
|
|
2896
|
-
lines = [
|
|
2897
|
-
"# Knowledge Context Pack",
|
|
2898
|
-
"",
|
|
2899
|
-
"## Query",
|
|
2900
|
-
"",
|
|
2901
|
-
query,
|
|
2902
|
-
"",
|
|
2903
|
-
"## Before planning",
|
|
2904
|
-
"",
|
|
2905
|
-
"- Treat candidate entries as useful history, not current truth.",
|
|
2906
|
-
"- Re-check stale entries against the current code before relying on them.",
|
|
2907
|
-
"- Open the source archive and suggested files before making implementation decisions.",
|
|
2908
|
-
"",
|
|
2909
|
-
"## High-confidence relevant history",
|
|
2910
|
-
"",
|
|
2911
|
-
]
|
|
2912
|
-
if not active_like:
|
|
2913
|
-
lines.append("No active or candidate history matched this query.")
|
|
2914
|
-
for entry in active_like:
|
|
2915
|
-
lines.extend(render_entry_for_context(entry))
|
|
2916
|
-
|
|
2917
|
-
lines.extend(["", "## Potentially stale history", ""])
|
|
2918
|
-
if not stale:
|
|
2919
|
-
lines.append("No stale matched history detected.")
|
|
2920
|
-
for entry in stale:
|
|
2921
|
-
lines.extend(render_entry_for_context(entry, include_stale=True))
|
|
2922
|
-
|
|
2923
|
-
lines.extend(["", "## Related risks", ""])
|
|
2924
|
-
if not risks:
|
|
2925
|
-
lines.append("No related risks matched this query.")
|
|
2926
|
-
for entry in risks[:8]:
|
|
2927
|
-
lines.append(f"- {entry['title']}")
|
|
2928
|
-
lines.append(f" - source: `{entry['source']['archive']}`")
|
|
2929
|
-
|
|
2930
|
-
lines.extend(["", "## Suggested files to inspect next", ""])
|
|
2931
|
-
if not files:
|
|
2932
|
-
lines.append("No source files were linked by matched entries.")
|
|
2933
|
-
for source_file in files[:20]:
|
|
2934
|
-
lines.append(f"- {source_file}")
|
|
2935
|
-
|
|
2936
|
-
lines.extend(["", "## Source index", ""])
|
|
2937
|
-
lines.append(f"- project: `{project}`")
|
|
2938
|
-
lines.append(f"- generatedAt: {now_iso()}")
|
|
2939
|
-
write_text(path, "\n".join(lines) + "\n")
|
|
2940
|
-
write_json(
|
|
2941
|
-
knowledge / "context-packs" / "latest.json",
|
|
2942
|
-
{
|
|
2943
|
-
"schemaVersion": 1,
|
|
2944
|
-
"generatedAt": now_iso(),
|
|
2945
|
-
"query": query,
|
|
2946
|
-
"contextPack": str(path),
|
|
2947
|
-
"matchIds": [entry["id"] for entry in entries],
|
|
2948
|
-
},
|
|
2949
|
-
)
|
|
2950
|
-
return path
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
def render_entry_for_context(entry: dict[str, Any], include_stale: bool = False) -> list[str]:
|
|
2954
|
-
lines = [
|
|
2955
|
-
f"- {entry['title']}",
|
|
2956
|
-
f" - type: `{entry['type']}`",
|
|
2957
|
-
f" - status: `{entry['status']}`",
|
|
2958
|
-
f" - source: `{entry['source']['archive']}`",
|
|
2959
|
-
f" - commit: `{entry['source']['sourceCommit'] or 'unknown'}`",
|
|
2960
|
-
f" - key takeaway: {entry['summary']}",
|
|
2961
|
-
]
|
|
2962
|
-
if entry["scope"]["sourceFiles"]:
|
|
2963
|
-
lines.append(" - source files: " + ", ".join(f"`{f}`" for f in entry["scope"]["sourceFiles"][:6]))
|
|
2964
|
-
if include_stale:
|
|
2965
|
-
reasons = entry["lifecycle"].get("staleReasons") or []
|
|
2966
|
-
for reason in reasons:
|
|
2967
|
-
lines.append(f" - stale reason: {reason}")
|
|
2968
|
-
return lines
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
# ---------------------------------------------------------------------------
|
|
2972
|
-
# P0-3: rule automation + AI judgement interface (DESIGN.md D4)
|
|
2973
|
-
# ---------------------------------------------------------------------------
|
|
2974
|
-
|
|
2975
|
-
NEAR_DUPLICATE_THRESHOLD = 0.88
|
|
2976
|
-
AUTO_SUPERSEDE_SIMILARITY = 0.75
|
|
2977
|
-
AUTO_SUPERSEDE_WEAK_SIMILARITY = 0.55
|
|
2978
|
-
JUDGE_ACTIONS = {"promote", "drop", "supersede", "keep-conflict"}
|
|
2979
|
-
ENTRY_STATUSES = {"candidate", "active", "stale", "superseded", "conflicted"}
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
def load_harness_project_config(project: Path) -> dict[str, Any]:
|
|
2983
|
-
config_path = project / ".harness" / "config" / "harness.json"
|
|
2984
|
-
if not config_path.exists():
|
|
2985
|
-
return {}
|
|
2986
|
-
try:
|
|
2987
|
-
config = read_json(config_path)
|
|
2988
|
-
except (OSError, json.JSONDecodeError):
|
|
2989
|
-
return {}
|
|
2990
|
-
return config if isinstance(config, dict) else {}
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
def manual_review_enabled(project: Path) -> bool:
|
|
2994
|
-
harness_cfg = load_harness_project_config(project)
|
|
2995
|
-
knowledge_section = harness_cfg.get("knowledge")
|
|
2996
|
-
if isinstance(knowledge_section, dict) and "manualReview" in knowledge_section:
|
|
2997
|
-
return bool(knowledge_section.get("manualReview"))
|
|
2998
|
-
knowledge_cfg = load_config(project / ".harness" / "knowledge")
|
|
2999
|
-
if "manualReview" in knowledge_cfg:
|
|
3000
|
-
return bool(knowledge_cfg.get("manualReview"))
|
|
3001
|
-
nested = knowledge_cfg.get("knowledge")
|
|
3002
|
-
if isinstance(nested, dict) and "manualReview" in nested:
|
|
3003
|
-
return bool(nested.get("manualReview"))
|
|
3004
|
-
return False
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
def normalize_similarity_text(text: str) -> str:
|
|
3008
|
-
lowered = str(text or "").lower()
|
|
3009
|
-
lowered = re.sub(r"[\W_]+", " ", lowered, flags=re.UNICODE)
|
|
3010
|
-
return re.sub(r"\s+", " ", lowered).strip()
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
def entry_compare_text(entry: dict[str, Any]) -> str:
|
|
3014
|
-
return normalize_similarity_text(
|
|
3015
|
-
" ".join(
|
|
3016
|
-
[
|
|
3017
|
-
str(entry.get("title") or ""),
|
|
3018
|
-
str(entry.get("summary") or ""),
|
|
3019
|
-
str(entry.get("body") or ""),
|
|
3020
|
-
]
|
|
3021
|
-
)
|
|
3022
|
-
)
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
def entry_similarity(left: dict[str, Any], right: dict[str, Any]) -> float:
|
|
3026
|
-
left_text = entry_compare_text(left)
|
|
3027
|
-
right_text = entry_compare_text(right)
|
|
3028
|
-
if not left_text or not right_text:
|
|
3029
|
-
return 0.0
|
|
3030
|
-
return SequenceMatcher(None, left_text, right_text).ratio()
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
def status_rank(status: str | None) -> int:
|
|
3034
|
-
return {
|
|
3035
|
-
"active": 5,
|
|
3036
|
-
"candidate": 4,
|
|
3037
|
-
"stale": 3,
|
|
3038
|
-
"conflicted": 2,
|
|
3039
|
-
"superseded": 1,
|
|
3040
|
-
}.get(str(status or ""), 0)
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
def prefer_entry(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
|
|
3044
|
-
left_key = (
|
|
3045
|
-
status_rank(left.get("status")),
|
|
3046
|
-
len(str(left.get("body") or "")),
|
|
3047
|
-
len(left.get("scope", {}).get("sourceFiles") or []),
|
|
3048
|
-
str(left.get("id") or ""),
|
|
3049
|
-
)
|
|
3050
|
-
right_key = (
|
|
3051
|
-
status_rank(right.get("status")),
|
|
3052
|
-
len(str(right.get("body") or "")),
|
|
3053
|
-
len(right.get("scope", {}).get("sourceFiles") or []),
|
|
3054
|
-
str(right.get("id") or ""),
|
|
3055
|
-
)
|
|
3056
|
-
return left if left_key >= right_key else right
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
def merge_entry_provenance(keeper: dict[str, Any], absorbed: dict[str, Any]) -> None:
|
|
3060
|
-
keeper["keywords"] = sorted(
|
|
3061
|
-
{
|
|
3062
|
-
str(item)
|
|
3063
|
-
for item in list(keeper.get("keywords") or []) + list(absorbed.get("keywords") or [])
|
|
3064
|
-
if item
|
|
3065
|
-
}
|
|
3066
|
-
)
|
|
3067
|
-
scope = keeper.setdefault("scope", {})
|
|
3068
|
-
files = list(scope.get("sourceFiles") or [])
|
|
3069
|
-
for path in absorbed.get("scope", {}).get("sourceFiles") or []:
|
|
3070
|
-
if path and path not in files:
|
|
3071
|
-
files.append(path)
|
|
3072
|
-
scope["sourceFiles"] = files
|
|
3073
|
-
if scope.get("staleIfPathsChanged") is not None or absorbed.get("scope", {}).get("staleIfPathsChanged"):
|
|
3074
|
-
patterns = list(scope.get("staleIfPathsChanged") or [])
|
|
3075
|
-
for pattern in absorbed.get("scope", {}).get("staleIfPathsChanged") or []:
|
|
3076
|
-
if pattern and pattern not in patterns:
|
|
3077
|
-
patterns.append(pattern)
|
|
3078
|
-
scope["staleIfPathsChanged"] = patterns
|
|
3079
|
-
|
|
3080
|
-
lifecycle = keeper.setdefault("lifecycle", {})
|
|
3081
|
-
merged_from = lifecycle.setdefault("mergedFrom", [])
|
|
3082
|
-
if absorbed.get("id") and absorbed["id"] not in merged_from:
|
|
3083
|
-
merged_from.append(absorbed["id"])
|
|
3084
|
-
supersedes = lifecycle.setdefault("supersedes", [])
|
|
3085
|
-
for sid in absorbed.get("lifecycle", {}).get("supersedes") or []:
|
|
3086
|
-
if sid and sid not in supersedes:
|
|
3087
|
-
supersedes.append(sid)
|
|
3088
|
-
if absorbed.get("id") and absorbed["id"] not in supersedes:
|
|
3089
|
-
supersedes.append(absorbed["id"])
|
|
3090
|
-
for sid in absorbed.get("lifecycle", {}).get("mergedFrom") or []:
|
|
3091
|
-
if sid and sid not in merged_from:
|
|
3092
|
-
merged_from.append(sid)
|
|
3093
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
def supersede_entry(entry: dict[str, Any], newer_id: str, reason: str) -> None:
|
|
3097
|
-
previous = str(entry.get("status") or "candidate")
|
|
3098
|
-
entry["status"] = "superseded"
|
|
3099
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
3100
|
-
lifecycle["previousStatus"] = previous
|
|
3101
|
-
lifecycle["supersededBy"] = newer_id
|
|
3102
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
3103
|
-
if reason not in reasons:
|
|
3104
|
-
reasons.append(reason)
|
|
3105
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
def dedupe_near_duplicates(
|
|
3109
|
-
entries: list[dict[str, Any]],
|
|
3110
|
-
threshold: float = NEAR_DUPLICATE_THRESHOLD,
|
|
3111
|
-
) -> dict[str, Any]:
|
|
3112
|
-
"""Merge near-duplicate entries within the same archive (in-place)."""
|
|
3113
|
-
by_archive: dict[str, list[dict[str, Any]]] = {}
|
|
3114
|
-
for entry in entries:
|
|
3115
|
-
if entry.get("status") == "superseded":
|
|
3116
|
-
continue
|
|
3117
|
-
archive = str(entry.get("source", {}).get("archive") or "")
|
|
3118
|
-
by_archive.setdefault(archive, []).append(entry)
|
|
3119
|
-
|
|
3120
|
-
merges: list[dict[str, Any]] = []
|
|
3121
|
-
for archive, group in by_archive.items():
|
|
3122
|
-
remaining = list(group)
|
|
3123
|
-
while remaining:
|
|
3124
|
-
current = remaining.pop(0)
|
|
3125
|
-
if current.get("status") == "superseded":
|
|
3126
|
-
continue
|
|
3127
|
-
cluster = [current]
|
|
3128
|
-
still: list[dict[str, Any]] = []
|
|
3129
|
-
for other in remaining:
|
|
3130
|
-
if other.get("status") == "superseded":
|
|
3131
|
-
continue
|
|
3132
|
-
if other.get("type") != current.get("type"):
|
|
3133
|
-
still.append(other)
|
|
3134
|
-
continue
|
|
3135
|
-
if entry_similarity(current, other) >= threshold:
|
|
3136
|
-
cluster.append(other)
|
|
3137
|
-
else:
|
|
3138
|
-
still.append(other)
|
|
3139
|
-
remaining = still
|
|
3140
|
-
if len(cluster) < 2:
|
|
3141
|
-
continue
|
|
3142
|
-
keeper = cluster[0]
|
|
3143
|
-
for candidate in cluster[1:]:
|
|
3144
|
-
keeper = prefer_entry(keeper, candidate)
|
|
3145
|
-
for absorbed in cluster:
|
|
3146
|
-
if absorbed is keeper or absorbed.get("id") == keeper.get("id"):
|
|
3147
|
-
continue
|
|
3148
|
-
merge_entry_provenance(keeper, absorbed)
|
|
3149
|
-
supersede_entry(
|
|
3150
|
-
absorbed,
|
|
3151
|
-
str(keeper["id"]),
|
|
3152
|
-
"near-duplicate merged into: " + str(keeper["id"]),
|
|
3153
|
-
)
|
|
3154
|
-
merges.append(
|
|
3155
|
-
{
|
|
3156
|
-
"keptId": keeper["id"],
|
|
3157
|
-
"mergedId": absorbed["id"],
|
|
3158
|
-
"archive": archive,
|
|
3159
|
-
"similarity": round(entry_similarity(keeper, absorbed), 3),
|
|
3160
|
-
}
|
|
3161
|
-
)
|
|
3162
|
-
return {"merged": len(merges), "merges": merges}
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
def relocate_entry_file(
|
|
3166
|
-
knowledge: Path,
|
|
3167
|
-
entry: dict[str, Any],
|
|
3168
|
-
source_path: Path | None = None,
|
|
3169
|
-
) -> Path:
|
|
3170
|
-
status = str(entry.get("status") or "candidate")
|
|
3171
|
-
if status not in ENTRY_STATUSES:
|
|
3172
|
-
status = "candidate"
|
|
3173
|
-
entry["status"] = status
|
|
3174
|
-
target = knowledge / "entries" / status / entry_filename(entry)
|
|
3175
|
-
write_json(target, entry)
|
|
3176
|
-
if source_path is not None and source_path.exists() and source_path.resolve() != target.resolve():
|
|
3177
|
-
source_path.unlink()
|
|
3178
|
-
return target
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
def persist_entries_by_status(
|
|
3182
|
-
knowledge: Path,
|
|
3183
|
-
path_by_id: dict[str, Path],
|
|
3184
|
-
entries: list[dict[str, Any]],
|
|
3185
|
-
) -> None:
|
|
3186
|
-
for entry in entries:
|
|
3187
|
-
entry_id = str(entry.get("id") or "")
|
|
3188
|
-
if not entry_id:
|
|
3189
|
-
continue
|
|
3190
|
-
source_path = path_by_id.get(entry_id)
|
|
3191
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
def dedupe_knowledge(project: Path, threshold: float = NEAR_DUPLICATE_THRESHOLD) -> dict[str, Any]:
|
|
3195
|
-
project = project.resolve()
|
|
3196
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3197
|
-
if not (knowledge / "index.json").exists():
|
|
3198
|
-
build_index(project)
|
|
3199
|
-
loaded = load_entry_files(knowledge)
|
|
3200
|
-
path_by_id = {entry["id"]: path for path, entry in loaded}
|
|
3201
|
-
entries = [entry for _, entry in loaded]
|
|
3202
|
-
result = dedupe_near_duplicates(entries, threshold=threshold)
|
|
3203
|
-
if result["merged"]:
|
|
3204
|
-
persist_entries_by_status(knowledge, path_by_id, entries)
|
|
3205
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
3206
|
-
else:
|
|
3207
|
-
index = read_json(knowledge / "index.json") if (knowledge / "index.json").exists() else {}
|
|
3208
|
-
return {
|
|
3209
|
-
"project": str(project),
|
|
3210
|
-
"generatedAt": now_iso(),
|
|
3211
|
-
"merged": result["merged"],
|
|
3212
|
-
"merges": result["merges"],
|
|
3213
|
-
"stats": index.get("stats") if isinstance(index, dict) else {},
|
|
3214
|
-
"paths": {
|
|
3215
|
-
"index": str(knowledge / "index.json"),
|
|
3216
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
3217
|
-
},
|
|
3218
|
-
}
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
def is_clear_topic_evolution(older: dict[str, Any], newer: dict[str, Any]) -> bool:
|
|
3222
|
-
"""Return True only when older→newer is an obvious same-topic evolution."""
|
|
3223
|
-
if older.get("type") != newer.get("type"):
|
|
3224
|
-
return False
|
|
3225
|
-
if older.get("status") in {"superseded", "conflicted"}:
|
|
3226
|
-
return False
|
|
3227
|
-
if newer.get("status") in {"superseded", "conflicted"}:
|
|
3228
|
-
return False
|
|
3229
|
-
older_archive = str(older.get("source", {}).get("archive") or "")
|
|
3230
|
-
newer_archive = str(newer.get("source", {}).get("archive") or "")
|
|
3231
|
-
if not older_archive or not newer_archive or older_archive == newer_archive:
|
|
3232
|
-
return False
|
|
3233
|
-
if archive_sort_key(newer) <= archive_sort_key(older):
|
|
3234
|
-
return False
|
|
3235
|
-
older_files = set(older.get("scope", {}).get("sourceFiles") or [])
|
|
3236
|
-
newer_files = set(newer.get("scope", {}).get("sourceFiles") or [])
|
|
3237
|
-
if not older_files or not newer_files or not (older_files & newer_files):
|
|
3238
|
-
return False
|
|
3239
|
-
# Semantic conflicts are left for judge.
|
|
3240
|
-
if entries_conflict_for_review(older, newer):
|
|
3241
|
-
return False
|
|
3242
|
-
shared_terms = subject_terms(older) & subject_terms(newer)
|
|
3243
|
-
similarity = entry_similarity(older, newer)
|
|
3244
|
-
same_title = normalize_similarity_text(str(older.get("title") or "")) == normalize_similarity_text(
|
|
3245
|
-
str(newer.get("title") or "")
|
|
3246
|
-
)
|
|
3247
|
-
if similarity >= AUTO_SUPERSEDE_SIMILARITY:
|
|
3248
|
-
return True
|
|
3249
|
-
if same_title and (shared_terms or similarity >= AUTO_SUPERSEDE_WEAK_SIMILARITY):
|
|
3250
|
-
return True
|
|
3251
|
-
if len(shared_terms) >= 2 and similarity >= AUTO_SUPERSEDE_WEAK_SIMILARITY:
|
|
3252
|
-
return True
|
|
3253
|
-
return False
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
def auto_supersede_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
3257
|
-
actions: list[dict[str, Any]] = []
|
|
3258
|
-
ordered = sorted(entries, key=archive_sort_key)
|
|
3259
|
-
for idx, older in enumerate(ordered):
|
|
3260
|
-
if older.get("status") in {"superseded", "conflicted"}:
|
|
3261
|
-
continue
|
|
3262
|
-
for newer in ordered[idx + 1 :]:
|
|
3263
|
-
if not is_clear_topic_evolution(older, newer):
|
|
3264
|
-
continue
|
|
3265
|
-
supersede_entry(
|
|
3266
|
-
older,
|
|
3267
|
-
str(newer["id"]),
|
|
3268
|
-
"auto-superseded by newer same-topic entry: " + str(newer["id"]),
|
|
3269
|
-
)
|
|
3270
|
-
newer_life = newer.setdefault("lifecycle", {})
|
|
3271
|
-
supersedes = newer_life.setdefault("supersedes", [])
|
|
3272
|
-
if older["id"] not in supersedes:
|
|
3273
|
-
supersedes.append(older["id"])
|
|
3274
|
-
actions.append(
|
|
3275
|
-
{
|
|
3276
|
-
"id": older["id"],
|
|
3277
|
-
"supersededBy": newer["id"],
|
|
3278
|
-
"type": older.get("type"),
|
|
3279
|
-
"overlap": sorted(
|
|
3280
|
-
set(older.get("scope", {}).get("sourceFiles") or [])
|
|
3281
|
-
& set(newer.get("scope", {}).get("sourceFiles") or [])
|
|
3282
|
-
),
|
|
3283
|
-
}
|
|
3284
|
-
)
|
|
3285
|
-
break
|
|
3286
|
-
return actions
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
def auto_supersede_knowledge(project: Path) -> dict[str, Any]:
|
|
3290
|
-
project = project.resolve()
|
|
3291
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3292
|
-
if not (knowledge / "index.json").exists():
|
|
3293
|
-
build_index(project)
|
|
3294
|
-
loaded = load_entry_files(knowledge)
|
|
3295
|
-
path_by_id = {entry["id"]: path for path, entry in loaded}
|
|
3296
|
-
entries = [entry for _, entry in loaded]
|
|
3297
|
-
actions = auto_supersede_entries(entries)
|
|
3298
|
-
if actions:
|
|
3299
|
-
persist_entries_by_status(knowledge, path_by_id, entries)
|
|
3300
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
3301
|
-
else:
|
|
3302
|
-
index = read_json(knowledge / "index.json") if (knowledge / "index.json").exists() else {}
|
|
3303
|
-
return {
|
|
3304
|
-
"project": str(project),
|
|
3305
|
-
"generatedAt": now_iso(),
|
|
3306
|
-
"superseded": len(actions),
|
|
3307
|
-
"actions": actions,
|
|
3308
|
-
"stats": index.get("stats") if isinstance(index, dict) else {},
|
|
3309
|
-
"paths": {
|
|
3310
|
-
"index": str(knowledge / "index.json"),
|
|
3311
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
3312
|
-
},
|
|
3313
|
-
}
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
def previous_status_for_restore(entry: dict[str, Any]) -> str:
|
|
3317
|
-
lifecycle = entry.get("lifecycle") or {}
|
|
3318
|
-
previous = lifecycle.get("previousStatus")
|
|
3319
|
-
if previous in ENTRY_STATUSES and previous != "stale":
|
|
3320
|
-
return str(previous)
|
|
3321
|
-
if lifecycle.get("demotedAt") or lifecycle.get("autoDemoted") or lifecycle.get("promotedAt"):
|
|
3322
|
-
return "active"
|
|
3323
|
-
return "candidate"
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
def reverify_stale_knowledge(project: Path) -> dict[str, Any]:
|
|
3327
|
-
project = project.resolve()
|
|
3328
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3329
|
-
if not (knowledge / "index.json").exists():
|
|
3330
|
-
build_index(project)
|
|
3331
|
-
config = knowledge_validation_config(load_config(knowledge))
|
|
3332
|
-
restored: list[dict[str, Any]] = []
|
|
3333
|
-
kept_stale: list[dict[str, Any]] = []
|
|
3334
|
-
skipped: list[dict[str, Any]] = []
|
|
3335
|
-
|
|
3336
|
-
for source_path, entry in load_entry_files(knowledge, ["stale"]):
|
|
3337
|
-
validators = entry.get("validators")
|
|
3338
|
-
if not isinstance(validators, list) or not validators:
|
|
3339
|
-
skipped.append({"id": entry["id"], "reason": "no validators"})
|
|
3340
|
-
continue
|
|
3341
|
-
usable = [
|
|
3342
|
-
validator
|
|
3343
|
-
for validator in validators
|
|
3344
|
-
if isinstance(validator, dict)
|
|
3345
|
-
and str(validator.get("type") or "") in {"file_exists", "file_contains"}
|
|
3346
|
-
]
|
|
3347
|
-
if not usable:
|
|
3348
|
-
skipped.append({"id": entry["id"], "reason": "no file_exists/file_contains validators"})
|
|
3349
|
-
continue
|
|
3350
|
-
|
|
3351
|
-
results = [evaluate_validator(project, entry, validator, config) for validator in usable]
|
|
3352
|
-
statuses = [str(result.get("status")) for result in results]
|
|
3353
|
-
validation = {
|
|
3354
|
-
"validatedAt": now_iso(),
|
|
3355
|
-
"status": "failed" if "failed" in statuses else ("passed" if "passed" in statuses else "skipped"),
|
|
3356
|
-
"results": results,
|
|
3357
|
-
"reverify": True,
|
|
3358
|
-
}
|
|
3359
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
3360
|
-
lifecycle["validation"] = validation
|
|
3361
|
-
lifecycle["lastCheckedAt"] = validation["validatedAt"]
|
|
3362
|
-
|
|
3363
|
-
if validation["status"] == "passed":
|
|
3364
|
-
target_status = previous_status_for_restore(entry)
|
|
3365
|
-
entry["status"] = target_status
|
|
3366
|
-
lifecycle.pop("demotedAt", None)
|
|
3367
|
-
lifecycle.pop("demotionReason", None)
|
|
3368
|
-
lifecycle.pop("autoDemoted", None)
|
|
3369
|
-
stale_reasons = [
|
|
3370
|
-
reason
|
|
3371
|
-
for reason in (lifecycle.get("staleReasons") or [])
|
|
3372
|
-
if "validator failed" not in str(reason) and "reverify failed" not in str(reason)
|
|
3373
|
-
]
|
|
3374
|
-
lifecycle["staleReasons"] = stale_reasons
|
|
3375
|
-
lifecycle["reverifiedAt"] = validation["validatedAt"]
|
|
3376
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3377
|
-
restored.append({"id": entry["id"], "status": target_status})
|
|
3378
|
-
else:
|
|
3379
|
-
reason = "reverify failed: " + first_failed_validator_message(validation)
|
|
3380
|
-
reasons = lifecycle.setdefault("staleReasons", [])
|
|
3381
|
-
if reason not in reasons:
|
|
3382
|
-
reasons.append(reason)
|
|
3383
|
-
write_json(source_path, entry)
|
|
3384
|
-
kept_stale.append({"id": entry["id"], "reason": reason})
|
|
3385
|
-
|
|
3386
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
3387
|
-
return {
|
|
3388
|
-
"project": str(project),
|
|
3389
|
-
"generatedAt": now_iso(),
|
|
3390
|
-
"restored": len(restored),
|
|
3391
|
-
"keptStale": len(kept_stale),
|
|
3392
|
-
"skipped": len(skipped),
|
|
3393
|
-
"entries": {
|
|
3394
|
-
"restored": restored,
|
|
3395
|
-
"keptStale": kept_stale,
|
|
3396
|
-
"skipped": skipped,
|
|
3397
|
-
},
|
|
3398
|
-
"stats": index.get("stats", {}),
|
|
3399
|
-
"paths": {
|
|
3400
|
-
"index": str(knowledge / "index.json"),
|
|
3401
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
3402
|
-
},
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
def snapshot_entry_state(entry: dict[str, Any]) -> dict[str, Any]:
|
|
3407
|
-
return json_clone(
|
|
3408
|
-
{
|
|
3409
|
-
"schemaVersion": entry.get("schemaVersion", 1),
|
|
3410
|
-
"id": entry.get("id"),
|
|
3411
|
-
"projectId": entry.get("projectId"),
|
|
3412
|
-
"status": entry.get("status"),
|
|
3413
|
-
"title": entry.get("title"),
|
|
3414
|
-
"type": entry.get("type"),
|
|
3415
|
-
"lifecycle": entry.get("lifecycle") or {},
|
|
3416
|
-
"scope": entry.get("scope") or {},
|
|
3417
|
-
"keywords": entry.get("keywords") or [],
|
|
3418
|
-
"body": entry.get("body"),
|
|
3419
|
-
"summary": entry.get("summary"),
|
|
3420
|
-
"source": entry.get("source") or {},
|
|
3421
|
-
"validators": entry.get("validators"),
|
|
3422
|
-
"confidence": entry.get("confidence"),
|
|
3423
|
-
}
|
|
3424
|
-
)
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
def judge_export(project: Path) -> dict[str, Any]:
|
|
3428
|
-
project = project.resolve()
|
|
3429
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3430
|
-
if not (knowledge / "index.json").exists():
|
|
3431
|
-
build_index(project)
|
|
3432
|
-
entries = [entry for _, entry in load_entry_files(knowledge)]
|
|
3433
|
-
by_id = {entry["id"]: entry for entry in entries}
|
|
3434
|
-
|
|
3435
|
-
conflicts: list[dict[str, Any]] = []
|
|
3436
|
-
seen_pairs: set[tuple[str, str]] = set()
|
|
3437
|
-
for entry in entries:
|
|
3438
|
-
if entry.get("status") != "conflicted":
|
|
3439
|
-
continue
|
|
3440
|
-
for other_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
|
|
3441
|
-
pair = tuple(sorted([entry["id"], str(other_id)]))
|
|
3442
|
-
if pair in seen_pairs:
|
|
3443
|
-
continue
|
|
3444
|
-
seen_pairs.add(pair)
|
|
3445
|
-
other = by_id.get(str(other_id))
|
|
3446
|
-
conflicts.append(
|
|
3447
|
-
{
|
|
3448
|
-
"kind": "conflict",
|
|
3449
|
-
"ids": list(pair),
|
|
3450
|
-
"entries": [
|
|
3451
|
-
{
|
|
3452
|
-
"id": entry["id"],
|
|
3453
|
-
"type": entry.get("type"),
|
|
3454
|
-
"status": entry.get("status"),
|
|
3455
|
-
"title": entry.get("title"),
|
|
3456
|
-
"body": entry.get("body"),
|
|
3457
|
-
"summary": entry.get("summary"),
|
|
3458
|
-
"source": entry.get("source"),
|
|
3459
|
-
"scope": entry.get("scope"),
|
|
3460
|
-
"lifecycle": {
|
|
3461
|
-
"conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
|
|
3462
|
-
"staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
|
|
3463
|
-
},
|
|
3464
|
-
},
|
|
3465
|
-
{
|
|
3466
|
-
"id": other.get("id") if other else other_id,
|
|
3467
|
-
"type": other.get("type") if other else None,
|
|
3468
|
-
"status": other.get("status") if other else None,
|
|
3469
|
-
"title": other.get("title") if other else None,
|
|
3470
|
-
"body": other.get("body") if other else None,
|
|
3471
|
-
"summary": other.get("summary") if other else None,
|
|
3472
|
-
"source": other.get("source") if other else None,
|
|
3473
|
-
"scope": other.get("scope") if other else None,
|
|
3474
|
-
"lifecycle": {
|
|
3475
|
-
"conflictsWith": (other.get("lifecycle", {}) or {}).get("conflictsWith") or [],
|
|
3476
|
-
"staleReasons": (other.get("lifecycle", {}) or {}).get("staleReasons") or [],
|
|
3477
|
-
}
|
|
3478
|
-
if other
|
|
3479
|
-
else {},
|
|
3480
|
-
},
|
|
3481
|
-
],
|
|
3482
|
-
}
|
|
3483
|
-
)
|
|
3484
|
-
|
|
3485
|
-
promote_candidates: list[dict[str, Any]] = []
|
|
3486
|
-
for entry in entries:
|
|
3487
|
-
if entry.get("status") != "candidate":
|
|
3488
|
-
continue
|
|
3489
|
-
promote_candidates.append(
|
|
3490
|
-
{
|
|
3491
|
-
"kind": "promote-candidate",
|
|
3492
|
-
"id": entry["id"],
|
|
3493
|
-
"type": entry.get("type"),
|
|
3494
|
-
"status": entry.get("status"),
|
|
3495
|
-
"title": entry.get("title"),
|
|
3496
|
-
"body": entry.get("body"),
|
|
3497
|
-
"summary": entry.get("summary"),
|
|
3498
|
-
"source": entry.get("source"),
|
|
3499
|
-
"scope": entry.get("scope"),
|
|
3500
|
-
"confidence": entry.get("confidence"),
|
|
3501
|
-
"lifecycle": {
|
|
3502
|
-
"staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
|
|
3503
|
-
"conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
|
|
3504
|
-
},
|
|
3505
|
-
}
|
|
3506
|
-
)
|
|
3507
|
-
|
|
3508
|
-
payload = {
|
|
3509
|
-
"schemaVersion": 1,
|
|
3510
|
-
"generatedAt": now_iso(),
|
|
3511
|
-
"project": str(project),
|
|
3512
|
-
"manualReview": manual_review_enabled(project),
|
|
3513
|
-
"counts": {
|
|
3514
|
-
"conflicts": len(conflicts),
|
|
3515
|
-
"promoteCandidates": len(promote_candidates),
|
|
3516
|
-
"pending": len(conflicts) + len(promote_candidates),
|
|
3517
|
-
},
|
|
3518
|
-
"conflicts": conflicts,
|
|
3519
|
-
"promoteCandidates": promote_candidates,
|
|
3520
|
-
"actions": sorted(JUDGE_ACTIONS),
|
|
3521
|
-
}
|
|
3522
|
-
export_path = knowledge / "reports" / f"judge-export-{timestamp()}.json"
|
|
3523
|
-
write_json(export_path, payload)
|
|
3524
|
-
payload["exportPath"] = str(export_path)
|
|
3525
|
-
return payload
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
def apply_judge_decision(
|
|
3529
|
-
knowledge: Path,
|
|
3530
|
-
entry: dict[str, Any],
|
|
3531
|
-
source_path: Path,
|
|
3532
|
-
decision: dict[str, Any],
|
|
3533
|
-
by_id: dict[str, dict[str, Any]],
|
|
3534
|
-
path_by_id: dict[str, Path],
|
|
3535
|
-
) -> dict[str, Any]:
|
|
3536
|
-
action = str(decision.get("action") or "")
|
|
3537
|
-
reason = str(decision.get("reason") or "")
|
|
3538
|
-
before = snapshot_entry_state(entry)
|
|
3539
|
-
after_status = entry.get("status")
|
|
3540
|
-
|
|
3541
|
-
if action == "promote":
|
|
3542
|
-
entry["status"] = "active"
|
|
3543
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
3544
|
-
lifecycle["promotedAt"] = now_iso()
|
|
3545
|
-
lifecycle["promotionNote"] = reason or "judge promote"
|
|
3546
|
-
lifecycle["judgeAction"] = action
|
|
3547
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
3548
|
-
# Clear conflict markers when promoting a resolved conflict side.
|
|
3549
|
-
lifecycle["conflictsWith"] = []
|
|
3550
|
-
stale_reasons = [
|
|
3551
|
-
item
|
|
3552
|
-
for item in (lifecycle.get("staleReasons") or [])
|
|
3553
|
-
if "potential conflict with" not in str(item)
|
|
3554
|
-
]
|
|
3555
|
-
lifecycle["staleReasons"] = stale_reasons
|
|
3556
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3557
|
-
path_by_id[entry["id"]] = knowledge / "entries" / "active" / entry_filename(entry)
|
|
3558
|
-
after_status = "active"
|
|
3559
|
-
elif action == "drop":
|
|
3560
|
-
supersede_entry(entry, str(decision.get("supersededBy") or entry["id"]), reason or "judge drop")
|
|
3561
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
3562
|
-
lifecycle["judgeAction"] = action
|
|
3563
|
-
if not decision.get("supersededBy"):
|
|
3564
|
-
lifecycle["supersededBy"] = None
|
|
3565
|
-
lifecycle["droppedByJudge"] = True
|
|
3566
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3567
|
-
path_by_id[entry["id"]] = knowledge / "entries" / "superseded" / entry_filename(entry)
|
|
3568
|
-
after_status = "superseded"
|
|
3569
|
-
elif action == "supersede":
|
|
3570
|
-
target_id = str(decision.get("supersededBy") or "")
|
|
3571
|
-
if not target_id:
|
|
3572
|
-
raise ValueError(f"supersede action requires supersededBy for entry {entry['id']}")
|
|
3573
|
-
supersede_entry(entry, target_id, reason or ("judge supersede by: " + target_id))
|
|
3574
|
-
entry.setdefault("lifecycle", {})["judgeAction"] = action
|
|
3575
|
-
target = by_id.get(target_id)
|
|
3576
|
-
if target is not None:
|
|
3577
|
-
supersedes = target.setdefault("lifecycle", {}).setdefault("supersedes", [])
|
|
3578
|
-
if entry["id"] not in supersedes:
|
|
3579
|
-
supersedes.append(entry["id"])
|
|
3580
|
-
target_path = path_by_id.get(target_id)
|
|
3581
|
-
relocate_entry_file(knowledge, target, target_path)
|
|
3582
|
-
path_by_id[target_id] = knowledge / "entries" / str(target.get("status")) / entry_filename(target)
|
|
3583
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3584
|
-
path_by_id[entry["id"]] = knowledge / "entries" / "superseded" / entry_filename(entry)
|
|
3585
|
-
after_status = "superseded"
|
|
3586
|
-
elif action == "keep-conflict":
|
|
3587
|
-
entry["status"] = "conflicted"
|
|
3588
|
-
lifecycle = entry.setdefault("lifecycle", {})
|
|
3589
|
-
lifecycle["judgeAction"] = action
|
|
3590
|
-
lifecycle["judgeReason"] = reason
|
|
3591
|
-
lifecycle["lastCheckedAt"] = now_iso()
|
|
3592
|
-
relocate_entry_file(knowledge, entry, source_path)
|
|
3593
|
-
path_by_id[entry["id"]] = knowledge / "entries" / "conflicted" / entry_filename(entry)
|
|
3594
|
-
after_status = "conflicted"
|
|
3595
|
-
else:
|
|
3596
|
-
raise ValueError(f"unsupported judge action: {action}")
|
|
3597
|
-
|
|
3598
|
-
return {
|
|
3599
|
-
"id": entry["id"],
|
|
3600
|
-
"action": action,
|
|
3601
|
-
"reason": reason,
|
|
3602
|
-
"before": before,
|
|
3603
|
-
"after": {"status": after_status, "supersededBy": entry.get("lifecycle", {}).get("supersededBy")},
|
|
3604
|
-
"supersededBy": decision.get("supersededBy"),
|
|
3605
|
-
}
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dict[str, Any]:
|
|
3609
|
-
project = project.resolve()
|
|
3610
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3611
|
-
if not (knowledge / "index.json").exists():
|
|
3612
|
-
build_index(project)
|
|
3613
|
-
if manual_review_enabled(project) and not force:
|
|
3614
|
-
raise ValueError("knowledge.manualReview=true; pass --force to apply judgements")
|
|
3615
|
-
|
|
3616
|
-
raw = read_json(decisions_path)
|
|
3617
|
-
if isinstance(raw, dict):
|
|
3618
|
-
decisions = raw.get("decisions")
|
|
3619
|
-
else:
|
|
3620
|
-
decisions = raw
|
|
3621
|
-
if not isinstance(decisions, list):
|
|
3622
|
-
raise ValueError("decisions file must be a list or an object with decisions[]")
|
|
3623
|
-
|
|
3624
|
-
loaded = load_entry_files(knowledge)
|
|
3625
|
-
path_by_id = {entry["id"]: path for path, entry in loaded}
|
|
3626
|
-
by_id = {entry["id"]: entry for _, entry in loaded}
|
|
3627
|
-
applied: list[dict[str, Any]] = []
|
|
3628
|
-
errors: list[dict[str, str]] = []
|
|
3629
|
-
|
|
3630
|
-
for item in decisions:
|
|
3631
|
-
if not isinstance(item, dict):
|
|
3632
|
-
errors.append({"error": "decision must be an object"})
|
|
3633
|
-
continue
|
|
3634
|
-
entry_id = str(item.get("id") or "")
|
|
3635
|
-
action = str(item.get("action") or "")
|
|
3636
|
-
if not entry_id or action not in JUDGE_ACTIONS:
|
|
3637
|
-
errors.append({"id": entry_id, "error": f"invalid decision: action={action}"})
|
|
3638
|
-
continue
|
|
3639
|
-
found = by_id.get(entry_id)
|
|
3640
|
-
source_path = path_by_id.get(entry_id)
|
|
3641
|
-
if found is None or source_path is None:
|
|
3642
|
-
errors.append({"id": entry_id, "error": "entry not found"})
|
|
3643
|
-
continue
|
|
3644
|
-
try:
|
|
3645
|
-
record = apply_judge_decision(knowledge, found, source_path, item, by_id, path_by_id)
|
|
3646
|
-
applied.append(record)
|
|
3647
|
-
except ValueError as exc:
|
|
3648
|
-
errors.append({"id": entry_id, "error": str(exc)})
|
|
3649
|
-
|
|
3650
|
-
judgement = {
|
|
3651
|
-
"schemaVersion": 1,
|
|
3652
|
-
"generatedAt": now_iso(),
|
|
3653
|
-
"project": str(project),
|
|
3654
|
-
"sourceDecisions": str(decisions_path),
|
|
3655
|
-
"manualReview": manual_review_enabled(project),
|
|
3656
|
-
"forced": force,
|
|
3657
|
-
"applied": applied,
|
|
3658
|
-
"errors": errors,
|
|
3659
|
-
}
|
|
3660
|
-
judgement_path = knowledge / "reports" / f"judgements-{timestamp()}.json"
|
|
3661
|
-
write_json(judgement_path, judgement)
|
|
3662
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
3663
|
-
return {
|
|
3664
|
-
"project": str(project),
|
|
3665
|
-
"generatedAt": judgement["generatedAt"],
|
|
3666
|
-
"judgement": str(judgement_path),
|
|
3667
|
-
"applied": len(applied),
|
|
3668
|
-
"errors": errors,
|
|
3669
|
-
"stats": index.get("stats", {}),
|
|
3670
|
-
"paths": {
|
|
3671
|
-
"judgement": str(judgement_path),
|
|
3672
|
-
"index": str(knowledge / "index.json"),
|
|
3673
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
3674
|
-
},
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
def restore_entry_from_snapshot(knowledge: Path, before: dict[str, Any], current_path: Path | None) -> Path:
|
|
3679
|
-
entry = json_clone(before)
|
|
3680
|
-
if not entry.get("projectId"):
|
|
3681
|
-
entry["projectId"] = "unknown"
|
|
3682
|
-
if "schemaVersion" not in entry:
|
|
3683
|
-
entry["schemaVersion"] = 1
|
|
3684
|
-
status = str(entry.get("status") or "candidate")
|
|
3685
|
-
if status not in ENTRY_STATUSES:
|
|
3686
|
-
status = "candidate"
|
|
3687
|
-
entry["status"] = status
|
|
3688
|
-
target = knowledge / "entries" / status / entry_filename(entry)
|
|
3689
|
-
write_json(target, entry)
|
|
3690
|
-
if current_path is not None and current_path.exists() and current_path.resolve() != target.resolve():
|
|
3691
|
-
current_path.unlink()
|
|
3692
|
-
# Remove any stray copies of the same id in other status dirs.
|
|
3693
|
-
for status_name in ENTRY_STATUSES:
|
|
3694
|
-
for path in (knowledge / "entries" / status_name).glob("*.json"):
|
|
3695
|
-
if path.resolve() == target.resolve():
|
|
3696
|
-
continue
|
|
3697
|
-
try:
|
|
3698
|
-
other = read_json(path)
|
|
3699
|
-
except (OSError, json.JSONDecodeError):
|
|
3700
|
-
continue
|
|
3701
|
-
if isinstance(other, dict) and other.get("id") == entry.get("id"):
|
|
3702
|
-
path.unlink()
|
|
3703
|
-
return target
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
def rollback_judgement(project: Path, judgement_path: Path) -> dict[str, Any]:
|
|
3707
|
-
project = project.resolve()
|
|
3708
|
-
knowledge = project / ".harness" / "knowledge"
|
|
3709
|
-
judgement = read_json(judgement_path)
|
|
3710
|
-
if not isinstance(judgement, dict):
|
|
3711
|
-
raise ValueError("judgement file must be a JSON object")
|
|
3712
|
-
applied = judgement.get("applied")
|
|
3713
|
-
if not isinstance(applied, list):
|
|
3714
|
-
raise ValueError("judgement file missing applied[]")
|
|
3715
|
-
|
|
3716
|
-
restored: list[dict[str, Any]] = []
|
|
3717
|
-
for item in reversed(applied):
|
|
3718
|
-
if not isinstance(item, dict):
|
|
3719
|
-
continue
|
|
3720
|
-
before = item.get("before")
|
|
3721
|
-
if not isinstance(before, dict) or not before.get("id"):
|
|
3722
|
-
continue
|
|
3723
|
-
entry_id = str(before["id"])
|
|
3724
|
-
found = find_entry_file(knowledge, entry_id, list(ENTRY_STATUSES))
|
|
3725
|
-
current_path = found[0] if found else None
|
|
3726
|
-
restore_entry_from_snapshot(knowledge, before, current_path)
|
|
3727
|
-
restored.append({"id": entry_id, "status": before.get("status")})
|
|
3728
|
-
|
|
3729
|
-
index = refresh_outputs_from_entry_files(project, knowledge)
|
|
3730
|
-
return {
|
|
3731
|
-
"project": str(project),
|
|
3732
|
-
"generatedAt": now_iso(),
|
|
3733
|
-
"judgement": str(judgement_path),
|
|
3734
|
-
"restored": len(restored),
|
|
3735
|
-
"entries": restored,
|
|
3736
|
-
"stats": index.get("stats", {}),
|
|
3737
|
-
"paths": {
|
|
3738
|
-
"index": str(knowledge / "index.json"),
|
|
3739
|
-
"sqlite": str(knowledge / "index.sqlite"),
|
|
3740
|
-
},
|
|
3741
|
-
}
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
def _outbox_root(project: Path) -> Path:
|
|
3745
|
-
return project / ".harness" / "knowledge" / "maintenance-outbox"
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
def _outbox_item_path(outbox_root: Path, status: str, archive_id: str) -> Path:
|
|
3749
|
-
return outbox_root / status / f"{archive_id}.json"
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
def _move_outbox(
|
|
3753
|
-
outbox_root: Path, archive_id: str, from_status: str, to_status: str, item: dict[str, Any]
|
|
3754
|
-
) -> None:
|
|
3755
|
-
src = _outbox_item_path(outbox_root, from_status, archive_id)
|
|
3756
|
-
dst = _outbox_item_path(outbox_root, to_status, archive_id)
|
|
3757
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
3758
|
-
write_json(dst, item)
|
|
3759
|
-
if src.exists() and src.resolve() != dst.resolve():
|
|
3760
|
-
src.unlink()
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
def claim_outbox(outbox_root: Path, archive_id: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
3764
|
-
"""Atomically claim a pending or failed item -> running. Retryable from failed.
|
|
3765
|
-
Returns (item, current_status). If already running/completed, returns it as-is."""
|
|
3766
|
-
for from_status in ("pending", "failed", "pending-judge"):
|
|
3767
|
-
src = _outbox_item_path(outbox_root, from_status, archive_id)
|
|
3768
|
-
if src.is_file():
|
|
3769
|
-
try:
|
|
3770
|
-
dst = _outbox_item_path(outbox_root, "running", archive_id)
|
|
3771
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
3772
|
-
# os.replace is the ownership boundary: only the process that
|
|
3773
|
-
# wins this rename may perform maintenance for this archive.
|
|
3774
|
-
src.replace(dst)
|
|
3775
|
-
item = read_json(dst)
|
|
3776
|
-
except FileNotFoundError:
|
|
3777
|
-
continue
|
|
3778
|
-
except (OSError, json.JSONDecodeError):
|
|
3779
|
-
item = {
|
|
3780
|
-
"schemaVersion": 1,
|
|
3781
|
-
"archiveId": archive_id,
|
|
3782
|
-
"status": from_status,
|
|
3783
|
-
"attempts": 0,
|
|
3784
|
-
}
|
|
3785
|
-
item["status"] = "running"
|
|
3786
|
-
write_json(_outbox_item_path(outbox_root, "running", archive_id), item)
|
|
3787
|
-
return item, "running"
|
|
3788
|
-
for status in ("running", "completed", "pending-judge"):
|
|
3789
|
-
p = _outbox_item_path(outbox_root, status, archive_id)
|
|
3790
|
-
if p.is_file():
|
|
3791
|
-
try:
|
|
3792
|
-
return read_json(p), status
|
|
3793
|
-
except (OSError, json.JSONDecodeError):
|
|
3794
|
-
pass
|
|
3795
|
-
return None, None
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
def maintain_knowledge(project: Path, archive_id: str) -> dict[str, Any]:
|
|
3799
|
-
"""§8.3: single-process maintenance. Claim pending->running, incremental
|
|
3800
|
-
ingest (build_index already does in-memory near-dedupe -- do NOT re-run a
|
|
3801
|
-
disk-based dedupe pass), auto-supersede, reverify-stale, export residual
|
|
3802
|
-
judge checklist, running->completed (or completed_rules_pending_judge).
|
|
3803
|
-
Idempotent for completed items. Failure -> failed, attempts+1, retryable."""
|
|
3804
|
-
project = project.resolve()
|
|
3805
|
-
outbox_root = _outbox_root(project)
|
|
3806
|
-
outbox_root.mkdir(parents=True, exist_ok=True)
|
|
3807
|
-
|
|
3808
|
-
item, status = claim_outbox(outbox_root, archive_id)
|
|
3809
|
-
if item is None:
|
|
3810
|
-
return {
|
|
3811
|
-
"ok": False,
|
|
3812
|
-
"archiveId": archive_id,
|
|
3813
|
-
"status": "not-found",
|
|
3814
|
-
"error": "no pending/failed/running/completed outbox item",
|
|
3815
|
-
}
|
|
3816
|
-
if status == "completed":
|
|
3817
|
-
return {
|
|
3818
|
-
"ok": True,
|
|
3819
|
-
"archiveId": archive_id,
|
|
3820
|
-
"status": str(item.get("status") or "completed"),
|
|
3821
|
-
"pendingJudgements": int(item.get("pendingJudgements") or 0),
|
|
3822
|
-
"idempotent": True,
|
|
3823
|
-
}
|
|
3824
|
-
|
|
3825
|
-
try:
|
|
3826
|
-
# 2-3. incremental ingest (in-memory near-dedupe inside build_index)
|
|
3827
|
-
build_index(project, incremental=True)
|
|
3828
|
-
# A failure here means the archive has not completed maintenance.
|
|
3829
|
-
# Never convert that failure into a successful completed outbox entry.
|
|
3830
|
-
auto_supersede_knowledge(project)
|
|
3831
|
-
reverify_stale_knowledge(project)
|
|
3832
|
-
# 6. export residual judge checklist
|
|
3833
|
-
judge_result = judge_export(project)
|
|
3834
|
-
pending = int((judge_result.get("counts") or {}).get("pending") or 0)
|
|
3835
|
-
if pending > 0:
|
|
3836
|
-
pj_path = (
|
|
3837
|
-
project
|
|
3838
|
-
/ ".harness"
|
|
3839
|
-
/ "knowledge"
|
|
3840
|
-
/ "reports"
|
|
3841
|
-
/ f"pending-judgements-{archive_id}.json"
|
|
3842
|
-
)
|
|
3843
|
-
write_json(pj_path, judge_result)
|
|
3844
|
-
final_status = "pending-judge"
|
|
3845
|
-
else:
|
|
3846
|
-
final_status = "completed"
|
|
3847
|
-
item["status"] = final_status
|
|
3848
|
-
item["pendingJudgements"] = pending
|
|
3849
|
-
item["completedAt"] = now_iso()
|
|
3850
|
-
item["lastError"] = None
|
|
3851
|
-
_move_outbox(outbox_root, archive_id, "running", final_status, item)
|
|
3852
|
-
return {
|
|
3853
|
-
"ok": True,
|
|
3854
|
-
"archiveId": archive_id,
|
|
3855
|
-
"status": final_status,
|
|
3856
|
-
"pendingJudgements": pending,
|
|
3857
|
-
}
|
|
3858
|
-
except Exception as exc:
|
|
3859
|
-
item["status"] = "failed"
|
|
3860
|
-
item["attempts"] = int(item.get("attempts") or 0) + 1
|
|
3861
|
-
item["lastError"] = str(exc)
|
|
3862
|
-
item["failedAt"] = now_iso()
|
|
3863
|
-
_move_outbox(outbox_root, archive_id, "running", "failed", item)
|
|
3864
|
-
return {
|
|
3865
|
-
"ok": False,
|
|
3866
|
-
"archiveId": archive_id,
|
|
3867
|
-
"status": "failed",
|
|
3868
|
-
"error": str(exc),
|
|
3869
|
-
"attempts": item["attempts"],
|
|
3870
|
-
}
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
def main(argv: list[str] | None = None) -> int:
|
|
3874
|
-
parser = argparse.ArgumentParser(description="Build and query Harness knowledge indexes.")
|
|
3875
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
3876
|
-
|
|
3877
|
-
ingest = sub.add_parser("ingest", help="Build .harness/knowledge from .harness/archive")
|
|
3878
|
-
ingest.add_argument("--project", default=".", help="Project root containing .harness/archive")
|
|
3879
|
-
ingest.add_argument(
|
|
3880
|
-
"--no-incremental",
|
|
3881
|
-
action="store_true",
|
|
3882
|
-
help="Re-extract every archive instead of reusing the archive entry cache",
|
|
3883
|
-
)
|
|
3884
|
-
|
|
3885
|
-
sync = sub.add_parser("sync", help="Check whether .harness/knowledge is current")
|
|
3886
|
-
sync.add_argument("--project", default=".", help="Project root containing .harness/archive")
|
|
3887
|
-
sync.add_argument("--update", action="store_true", help="Rebuild the index when it is out of date")
|
|
3888
|
-
sync.add_argument(
|
|
3889
|
-
"--no-incremental",
|
|
3890
|
-
action="store_true",
|
|
3891
|
-
help="When used with --update, rebuild without reusing the archive entry cache",
|
|
3892
|
-
)
|
|
3893
|
-
|
|
3894
|
-
auto = sub.add_parser("auto", help="Run the default automated knowledge maintenance workflow")
|
|
3895
|
-
auto.add_argument("--project", default=".", help="Project root containing .harness/archive")
|
|
3896
|
-
auto.add_argument("--limit", type=int, default=20, help="Maximum entries to suggest validators for")
|
|
3897
|
-
auto.add_argument("--audit-limit", type=int, default=10, help="Maximum audit entries per review section")
|
|
3898
|
-
auto.add_argument(
|
|
3899
|
-
"--suggest-status",
|
|
3900
|
-
action="append",
|
|
3901
|
-
dest="suggest_statuses",
|
|
3902
|
-
default=[],
|
|
3903
|
-
help="Only suggest validators for this lifecycle status",
|
|
3904
|
-
)
|
|
3905
|
-
auto.add_argument("--apply-suggestions", action="store_true", help="Write validator suggestions into entry JSON files")
|
|
3906
|
-
auto.add_argument("--no-incremental", action="store_true", help="Refresh without reusing the archive entry cache")
|
|
3907
|
-
|
|
3908
|
-
audit = sub.add_parser("audit", help="Generate review lists for candidate, stale, and superseded entries")
|
|
3909
|
-
audit.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3910
|
-
audit.add_argument("--limit", type=int, default=10)
|
|
3911
|
-
|
|
3912
|
-
verify = sub.add_parser("verify", help="Run configured validators against knowledge entries")
|
|
3913
|
-
verify.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3914
|
-
|
|
3915
|
-
suggest = sub.add_parser("suggest-validators", help="Suggest deterministic validators for knowledge entries")
|
|
3916
|
-
suggest.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3917
|
-
suggest.add_argument("--limit", type=int, default=20, help="Maximum entries to suggest validators for")
|
|
3918
|
-
suggest.add_argument("--status", action="append", dest="statuses", default=[], help="Only suggest for this lifecycle status")
|
|
3919
|
-
suggest.add_argument("--apply", action="store_true", help="Write suggested validators into entry JSON files")
|
|
3920
|
-
|
|
3921
|
-
query = sub.add_parser("query", help="Query .harness/knowledge and generate a context pack")
|
|
3922
|
-
query.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3923
|
-
query.add_argument("--query", required=True, help="Need, question, keyword, or file path to search")
|
|
3924
|
-
query.add_argument("--limit", type=int, default=10)
|
|
3925
|
-
query.add_argument("--file", action="append", dest="files", default=[], help="Only return entries linked to this source file")
|
|
3926
|
-
query.add_argument("--status", action="append", dest="statuses", default=[], help="Only return entries with this lifecycle status")
|
|
3927
|
-
query.add_argument("--type", action="append", dest="types", default=[], help="Only return entries of this knowledge type")
|
|
3928
|
-
|
|
3929
|
-
promote = sub.add_parser("promote", help="Promote a candidate knowledge entry to active")
|
|
3930
|
-
promote.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3931
|
-
promote.add_argument("--id", required=True, help="Knowledge entry id to promote")
|
|
3932
|
-
promote.add_argument("--note", default="", help="Manual verification note")
|
|
3933
|
-
promote.add_argument(
|
|
3934
|
-
"--allow-stale",
|
|
3935
|
-
action="store_true",
|
|
3936
|
-
help="Allow promoting a stale entry after manual verification",
|
|
3937
|
-
)
|
|
3938
|
-
|
|
3939
|
-
demote = sub.add_parser("demote", help="Demote an active entry after manual review")
|
|
3940
|
-
demote.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3941
|
-
demote.add_argument("--id", required=True, help="Active knowledge entry id to demote")
|
|
3942
|
-
demote.add_argument("--status", choices=["candidate", "stale"], required=True, help="Target lifecycle status")
|
|
3943
|
-
demote.add_argument("--reason", required=True, help="Manual demotion reason")
|
|
3944
|
-
|
|
3945
|
-
dedupe = sub.add_parser("dedupe", help="Merge near-duplicate entries within the same archive")
|
|
3946
|
-
dedupe.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3947
|
-
dedupe.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3948
|
-
|
|
3949
|
-
auto_supersede = sub.add_parser(
|
|
3950
|
-
"auto-supersede",
|
|
3951
|
-
help="Supersede older same-topic entries when evolution is clear",
|
|
3952
|
-
)
|
|
3953
|
-
auto_supersede.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3954
|
-
auto_supersede.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3955
|
-
|
|
3956
|
-
reverify = sub.add_parser(
|
|
3957
|
-
"reverify-stale",
|
|
3958
|
-
help="Re-run file_exists/file_contains validators on stale entries",
|
|
3959
|
-
)
|
|
3960
|
-
reverify.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3961
|
-
reverify.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3962
|
-
|
|
3963
|
-
judge = sub.add_parser("judge", help="Export or apply AI judgement decisions")
|
|
3964
|
-
judge.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3965
|
-
judge_mode = judge.add_mutually_exclusive_group(required=True)
|
|
3966
|
-
judge_mode.add_argument("--export", metavar="FILE", help="Write pending judgement checklist JSON")
|
|
3967
|
-
judge_mode.add_argument("--apply", metavar="FILE", help="Apply decisions JSON and write judgement log")
|
|
3968
|
-
judge.add_argument(
|
|
3969
|
-
"--force",
|
|
3970
|
-
action="store_true",
|
|
3971
|
-
help="Required when knowledge.manualReview=true before applying judgements",
|
|
3972
|
-
)
|
|
3973
|
-
judge.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3974
|
-
|
|
3975
|
-
rollback = sub.add_parser("rollback", help="Restore entry states from a judgement decision log")
|
|
3976
|
-
rollback.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3977
|
-
rollback.add_argument("--judgement", required=True, help="Path to judgements-*.json decision log")
|
|
3978
|
-
rollback.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3979
|
-
|
|
3980
|
-
maintain = sub.add_parser(
|
|
3981
|
-
"maintain",
|
|
3982
|
-
help="Single-process maintenance: claim outbox, ingest+dedupe, supersede, reverify, judge-export",
|
|
3983
|
-
)
|
|
3984
|
-
maintain.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
|
|
3985
|
-
maintain.add_argument("--archive-id", required=True, help="Archive id of the outbox item to maintain")
|
|
3986
|
-
maintain.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
|
|
3987
|
-
|
|
3988
|
-
args = parser.parse_args(argv)
|
|
3989
|
-
if args.command == "ingest":
|
|
3990
|
-
index = build_index(Path(args.project), incremental=not args.no_incremental)
|
|
3991
|
-
print(json.dumps(summarize_index(index), ensure_ascii=False, indent=2))
|
|
3992
|
-
return 0
|
|
3993
|
-
if args.command == "sync":
|
|
3994
|
-
result = sync_status(Path(args.project), args.update, incremental=not args.no_incremental)
|
|
3995
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
3996
|
-
return 0
|
|
3997
|
-
if args.command == "auto":
|
|
3998
|
-
result = auto_knowledge(
|
|
3999
|
-
Path(args.project),
|
|
4000
|
-
limit=args.limit,
|
|
4001
|
-
suggest_statuses=args.suggest_statuses,
|
|
4002
|
-
apply_suggestions=args.apply_suggestions,
|
|
4003
|
-
incremental=not args.no_incremental,
|
|
4004
|
-
audit_limit=args.audit_limit,
|
|
4005
|
-
)
|
|
4006
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4007
|
-
return 0
|
|
4008
|
-
if args.command == "audit":
|
|
4009
|
-
result = audit_entries(Path(args.project), args.limit)
|
|
4010
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4011
|
-
return 0
|
|
4012
|
-
if args.command == "verify":
|
|
4013
|
-
result = verify_knowledge(Path(args.project))
|
|
4014
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4015
|
-
return 0
|
|
4016
|
-
if args.command == "suggest-validators":
|
|
4017
|
-
result = suggest_validators(Path(args.project), args.limit, args.statuses, args.apply)
|
|
4018
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4019
|
-
return 0
|
|
4020
|
-
if args.command == "query":
|
|
4021
|
-
result = query_index(Path(args.project), args.query, args.limit, args.files, args.statuses, args.types)
|
|
4022
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4023
|
-
return 0
|
|
4024
|
-
if args.command == "promote":
|
|
4025
|
-
try:
|
|
4026
|
-
result = promote_entry(Path(args.project), args.id, args.note, args.allow_stale)
|
|
4027
|
-
except ValueError as exc:
|
|
4028
|
-
print(str(exc), file=sys.stderr)
|
|
4029
|
-
return 1
|
|
4030
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4031
|
-
return 0
|
|
4032
|
-
if args.command == "demote":
|
|
4033
|
-
try:
|
|
4034
|
-
result = demote_entry(Path(args.project), args.id, args.status, args.reason)
|
|
4035
|
-
except ValueError as exc:
|
|
4036
|
-
print(str(exc), file=sys.stderr)
|
|
4037
|
-
return 1
|
|
4038
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4039
|
-
return 0
|
|
4040
|
-
if args.command == "dedupe":
|
|
4041
|
-
result = dedupe_knowledge(Path(args.project))
|
|
4042
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4043
|
-
return 0
|
|
4044
|
-
if args.command == "auto-supersede":
|
|
4045
|
-
result = auto_supersede_knowledge(Path(args.project))
|
|
4046
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4047
|
-
return 0
|
|
4048
|
-
if args.command == "reverify-stale":
|
|
4049
|
-
result = reverify_stale_knowledge(Path(args.project))
|
|
4050
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4051
|
-
return 0
|
|
4052
|
-
if args.command == "judge":
|
|
4053
|
-
try:
|
|
4054
|
-
if args.export:
|
|
4055
|
-
result = judge_export(Path(args.project))
|
|
4056
|
-
export_target = Path(args.export)
|
|
4057
|
-
write_json(export_target, {k: v for k, v in result.items() if k != "exportPath"})
|
|
4058
|
-
result["exportPath"] = str(export_target.resolve())
|
|
4059
|
-
else:
|
|
4060
|
-
result = judge_apply(Path(args.project), Path(args.apply), force=args.force)
|
|
4061
|
-
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
4062
|
-
print(str(exc), file=sys.stderr)
|
|
4063
|
-
return 1
|
|
4064
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4065
|
-
return 0
|
|
4066
|
-
if args.command == "rollback":
|
|
4067
|
-
try:
|
|
4068
|
-
result = rollback_judgement(Path(args.project), Path(args.judgement))
|
|
4069
|
-
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
4070
|
-
print(str(exc), file=sys.stderr)
|
|
4071
|
-
return 1
|
|
4072
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4073
|
-
return 0
|
|
4074
|
-
if args.command == "maintain":
|
|
4075
|
-
result = maintain_knowledge(Path(args.project), args.archive_id)
|
|
4076
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
4077
|
-
return 0 if result.get("ok") else 1
|
|
4078
|
-
return 2
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
if __name__ == "__main__":
|
|
4082
|
-
raise SystemExit(main())
|