source-kb 0.2.2__py3-none-any.whl
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.
- cli/__init__.py +50 -0
- cli/__main__.py +5 -0
- cli/commands/__init__.py +1 -0
- cli/commands/anchor_fix.py +47 -0
- cli/commands/diff_doc.py +52 -0
- cli/commands/dispatch.py +77 -0
- cli/commands/extract.py +72 -0
- cli/commands/file_list.py +74 -0
- cli/commands/index.py +84 -0
- cli/commands/lock.py +89 -0
- cli/commands/merge.py +60 -0
- cli/commands/merge_delta.py +19 -0
- cli/commands/metadata.py +24 -0
- cli/commands/pipeline.py +45 -0
- cli/commands/post_merge.py +43 -0
- cli/commands/query.py +52 -0
- cli/commands/render.py +101 -0
- cli/commands/scan_repos.py +46 -0
- cli/commands/setup.py +94 -0
- cli/commands/split.py +196 -0
- cli/commands/stale_files.py +98 -0
- cli/commands/validate.py +191 -0
- core/__init__.py +32 -0
- core/config.py +261 -0
- core/docs/__init__.py +7 -0
- core/docs/section_updater.py +286 -0
- core/docs/shared.py +149 -0
- core/git.py +294 -0
- core/interfaces.py +249 -0
- core/monitor/__init__.py +5 -0
- core/monitor/progress.py +83 -0
- core/monitor/prompt_store.py +49 -0
- core/paths.py +141 -0
- core/preset.py +237 -0
- core/preset_accessors.py +202 -0
- core/preset_classify.py +132 -0
- core/preset_hooks.py +129 -0
- core/preset_profile.py +89 -0
- core/prompt/__init__.py +7 -0
- core/prompt/__main__.py +147 -0
- core/prompt/content.py +320 -0
- core/prompt/context_manager.py +164 -0
- core/prompt/renderer.py +236 -0
- core/prompt/response_parser.py +274 -0
- core/prompt/templates.py +357 -0
- core/prompt/validate_parity.py +162 -0
- core/prompt/variables.py +339 -0
- core/rag/__init__.py +22 -0
- core/rag/__main__.py +136 -0
- core/rag/bm25_index.py +268 -0
- core/rag/chunker.py +273 -0
- core/rag/embedder.py +151 -0
- core/rag/indexer.py +292 -0
- core/rag/loader.py +89 -0
- core/rag/retriever.py +82 -0
- core/skeleton/__init__.py +11 -0
- core/skeleton/__main__.py +934 -0
- core/skeleton/anchor_fix.py +250 -0
- core/skeleton/classify.py +331 -0
- core/skeleton/cmd_anchor_fix.py +43 -0
- core/skeleton/cmd_diff_doc.py +44 -0
- core/skeleton/cmd_lock.py +87 -0
- core/skeleton/cmd_merge_delta.py +41 -0
- core/skeleton/community.py +233 -0
- core/skeleton/dependency_graph.py +306 -0
- core/skeleton/diff_doc.py +248 -0
- core/skeleton/dispatch.py +273 -0
- core/skeleton/dispatch_render.py +319 -0
- core/skeleton/dispatch_source.py +111 -0
- core/skeleton/extract.py +218 -0
- core/skeleton/extract_methods.py +298 -0
- core/skeleton/file_list.py +239 -0
- core/skeleton/impact.py +278 -0
- core/skeleton/jar_download.py +177 -0
- core/skeleton/jar_resolver.py +186 -0
- core/skeleton/loader.py +162 -0
- core/skeleton/merge.py +278 -0
- core/skeleton/merge_delta.py +229 -0
- core/skeleton/metadata.py +96 -0
- core/skeleton/metadata_builders.py +264 -0
- core/skeleton/module_dag.py +330 -0
- core/skeleton/parsers/__init__.py +71 -0
- core/skeleton/parsers/jqassistant.py +300 -0
- core/skeleton/parsers/jqassistant_cypher.py +225 -0
- core/skeleton/parsers/regex.py +171 -0
- core/skeleton/parsers/treesitter.py +324 -0
- core/skeleton/parsers/treesitter_java.py +284 -0
- core/skeleton/parsers/treesitter_multi.py +289 -0
- core/skeleton/pom_parser.py +299 -0
- core/skeleton/post_merge.py +295 -0
- core/skeleton/post_merge_llm.py +82 -0
- core/skeleton/query.py +195 -0
- core/skeleton/shard_context.py +177 -0
- core/skeleton/split.py +180 -0
- core/skeleton/split_cache.py +107 -0
- core/skeleton/split_feedback.py +174 -0
- core/skeleton/split_plan.py +219 -0
- core/skeleton/split_plan_helpers.py +305 -0
- core/skeleton/split_plan_llm.py +274 -0
- core/utils.py +135 -0
- core/validators/__init__.py +65 -0
- core/validators/__main__.py +215 -0
- core/validators/consistency.py +203 -0
- core/validators/coverage.py +171 -0
- core/validators/duplicates.py +76 -0
- core/validators/engine.py +224 -0
- core/validators/links.py +76 -0
- core/validators/sampling.py +169 -0
- core/validators/structure.py +144 -0
- engine/__init__.py +7 -0
- engine/assembler.py +231 -0
- engine/confirm.py +65 -0
- engine/dedup.py +106 -0
- engine/main.py +211 -0
- engine/pipeline/__init__.py +163 -0
- engine/pipeline/recovery.py +250 -0
- engine/pipeline/steps/__init__.py +23 -0
- engine/pipeline/steps/audit.py +220 -0
- engine/pipeline/steps/audit_apply.py +195 -0
- engine/pipeline/steps/audit_helpers.py +155 -0
- engine/pipeline/steps/classify_llm.py +236 -0
- engine/pipeline/steps/classify_prompt.py +223 -0
- engine/pipeline/steps/finalize.py +160 -0
- engine/pipeline/steps/generate.py +169 -0
- engine/pipeline/steps/generate_batch.py +197 -0
- engine/pipeline/steps/generate_recovery.py +170 -0
- engine/pipeline/steps/llm_plan_split.py +253 -0
- engine/pipeline/steps/lock.py +64 -0
- engine/pipeline/steps/preflight.py +237 -0
- engine/pipeline/steps/preflight_adjust.py +147 -0
- engine/pipeline/steps/pregenerate.py +130 -0
- engine/pipeline/steps/quality.py +81 -0
- engine/pipeline/steps/skeleton.py +149 -0
- engine/pipeline/steps/source.py +163 -0
- engine/pipeline/steps/sync.py +117 -0
- engine/pipeline/steps/sync_finalize.py +237 -0
- engine/pipeline/steps/sync_update.py +341 -0
- engine/pipelines.py +91 -0
- engine/runner.py +335 -0
- engine/strategies/__init__.py +86 -0
- engine/strategies/api.py +128 -0
- engine/strategies/delegated.py +50 -0
- engine/strategies/dryrun.py +25 -0
- engine/two_phase.py +143 -0
- mcp_server/__init__.py +73 -0
- mcp_server/__main__.py +5 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/config.py +63 -0
- mcp_server/tools/discovery.py +276 -0
- mcp_server/tools/generation.py +184 -0
- mcp_server/tools/planning.py +144 -0
- mcp_server/tools/source.py +175 -0
- mcp_server/tools/validation.py +140 -0
- mcp_server/tools/workflow.py +166 -0
- mcp_server/workflow_loader.py +204 -0
- presets/generic/audit_dimensions.md +132 -0
- presets/generic/doc_types.yaml +152 -0
- presets/generic/preset.yaml +115 -0
- presets/java-spring/audit_dimensions.md +228 -0
- presets/java-spring/audit_dimensions.yaml +203 -0
- presets/java-spring/doc_types.yaml +269 -0
- presets/java-spring/hooks.py +122 -0
- presets/java-spring/preset.yaml +341 -0
- presets/java-spring/templates/README.md +34 -0
- presets/java-spring/templates/audit-system.md +15 -0
- presets/java-spring/templates/subagent-aop.md +105 -0
- presets/java-spring/templates/subagent-api.md +63 -0
- presets/java-spring/templates/subagent-architecture.md +111 -0
- presets/java-spring/templates/subagent-async-events.md +107 -0
- presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
- presets/java-spring/templates/subagent-audit-architecture.md +38 -0
- presets/java-spring/templates/subagent-audit-business.md +40 -0
- presets/java-spring/templates/subagent-audit-data-models.md +40 -0
- presets/java-spring/templates/subagent-business.md +129 -0
- presets/java-spring/templates/subagent-caching.md +75 -0
- presets/java-spring/templates/subagent-database-access.md +114 -0
- presets/java-spring/templates/subagent-enum.md +75 -0
- presets/java-spring/templates/subagent-error-handling.md +91 -0
- presets/java-spring/templates/subagent-external-integrations.md +80 -0
- presets/java-spring/templates/subagent-index.md +122 -0
- presets/java-spring/templates/subagent-messaging.md +97 -0
- presets/java-spring/templates/subagent-model.md +88 -0
- presets/java-spring/templates/subagent-observability.md +91 -0
- presets/java-spring/templates/subagent-scheduled.md +81 -0
- presets/java-spring/templates/subagent-security.md +102 -0
- presets/java-spring/templates/subagent-structure.md +101 -0
- presets/java-spring/templates/subagent-sync-section.md +34 -0
- presets/java-spring/templates/subagent-utils.md +73 -0
- presets/java-spring/templates/sync-system.md +8 -0
- presets/java-spring/workflow-extensions.md +112 -0
- skills/__init__.py +1 -0
- skills/_shared/README.md +30 -0
- skills/_shared/doc-coverage-shared.md +134 -0
- skills/_shared/doc-quality-standard.md +1058 -0
- skills/_shared/doc-subagent-rules.md +762 -0
- skills/_shared/windows-compat.md +89 -0
- skills/kb-audit/SKILL.md +52 -0
- skills/kb-audit/rules.md +88 -0
- skills/kb-audit/steps/step-01-prepare.md +75 -0
- skills/kb-audit/steps/step-02-audit.md +96 -0
- skills/kb-audit/steps/step-03-verify.md +65 -0
- skills/kb-audit/steps/step-04-report.md +64 -0
- skills/kb-init/SKILL.md +146 -0
- skills/kb-init/rules.md +187 -0
- skills/kb-init/steps/step-01-scope.md +62 -0
- skills/kb-init/steps/step-02-source.md +410 -0
- skills/kb-init/steps/step-03-generate.md +307 -0
- skills/kb-init/steps/step-04-quality.md +92 -0
- skills/kb-init/steps/step-05-finalize.md +132 -0
- skills/kb-init/templates/core/execution-modes.md +29 -0
- skills/kb-init/templates/core/output-only.md +4 -0
- skills/kb-init/templates/core/readwrite.md +33 -0
- skills/kb-search/SKILL.md +138 -0
- skills/kb-search/rules.md +64 -0
- skills/kb-sync/SKILL.md +43 -0
- skills/kb-sync/rules.md +70 -0
- skills/kb-sync/scripts/rebuild_module.py +91 -0
- skills/kb-sync/scripts/scan_repos.py +687 -0
- skills/kb-sync/steps/step-01-detect.md +72 -0
- skills/kb-sync/steps/step-02-update.md +71 -0
- skills/kb-sync/steps/step-03-verify.md +47 -0
- skills/kb-sync/steps/step-04-finalize.md +52 -0
- source_kb-0.2.2.dist-info/METADATA +194 -0
- source_kb-0.2.2.dist-info/RECORD +228 -0
- source_kb-0.2.2.dist-info/WHEEL +5 -0
- source_kb-0.2.2.dist-info/entry_points.txt +3 -0
- source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
- source_kb-0.2.2.dist-info/top_level.txt +6 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
"""CLI entry points for core/skeleton tools.
|
|
2
|
+
|
|
3
|
+
Provides sub-commands for Agent mode to call directly:
|
|
4
|
+
python -m core.skeleton extract ...
|
|
5
|
+
python -m core.skeleton query ...
|
|
6
|
+
python -m core.skeleton file-list ...
|
|
7
|
+
python -m core.skeleton metadata ...
|
|
8
|
+
python -m core.skeleton dispatch-preview ...
|
|
9
|
+
python -m core.skeleton module-dag ...
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_doc_type_files(preset: dict, doc_type: str) -> list[str] | None:
|
|
23
|
+
"""Resolve which file classification categories are relevant to a doc type.
|
|
24
|
+
|
|
25
|
+
Returns None if doc_type is invalid (not in doc_types.yaml).
|
|
26
|
+
Returns empty list if valid but no categories map to it.
|
|
27
|
+
Note: This returns category names, not actual file paths — the extract
|
|
28
|
+
function uses the `files` param to filter after full extraction.
|
|
29
|
+
"""
|
|
30
|
+
doc_types = preset.get("doc_types", {})
|
|
31
|
+
if doc_type not in doc_types:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
# Get the filename for this doc type
|
|
35
|
+
dt_config = doc_types[doc_type]
|
|
36
|
+
if not isinstance(dt_config, dict):
|
|
37
|
+
return []
|
|
38
|
+
target_filename = dt_config.get("filename", f"{doc_type}.md")
|
|
39
|
+
|
|
40
|
+
# Reverse lookup: find classification categories whose 'affects' includes this filename
|
|
41
|
+
classification = preset.get("file_classification", {})
|
|
42
|
+
# We can't resolve actual file paths without a skeleton, so return None
|
|
43
|
+
# to indicate "use full extraction then filter" — the --doc param is mainly
|
|
44
|
+
# a validation + documentation feature for now
|
|
45
|
+
return [] # Empty means "don't filter" — full extract, doc_type noted in output
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_extract(args: argparse.Namespace) -> None:
|
|
49
|
+
"""Extract skeleton from repository."""
|
|
50
|
+
from core.skeleton.extract import extract_skeleton
|
|
51
|
+
from core.preset import load_preset
|
|
52
|
+
|
|
53
|
+
preset = load_preset(args.preset)
|
|
54
|
+
repo_path = Path(args.repo)
|
|
55
|
+
output_dir = Path(args.output) if args.output else None
|
|
56
|
+
|
|
57
|
+
# Handle --doc parameter: filter to files relevant to a doc type
|
|
58
|
+
files_filter = getattr(args, "files", None)
|
|
59
|
+
doc_filter = getattr(args, "doc", None)
|
|
60
|
+
if doc_filter and not files_filter:
|
|
61
|
+
doc_files = _resolve_doc_type_files(preset, doc_filter)
|
|
62
|
+
if doc_files is None:
|
|
63
|
+
# Invalid doc type
|
|
64
|
+
doc_types = list(preset.get("doc_types", {}).keys())
|
|
65
|
+
print(f"Error: unknown doc type '{doc_filter}'. Valid: {', '.join(doc_types)}",
|
|
66
|
+
file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
files_filter = doc_files if doc_files else None
|
|
69
|
+
|
|
70
|
+
entries = extract_skeleton(
|
|
71
|
+
repo_path, preset,
|
|
72
|
+
ref=args.ref or "HEAD",
|
|
73
|
+
subpath=getattr(args, "subpath", None),
|
|
74
|
+
output_dir=output_dir,
|
|
75
|
+
split_by_package=args.split_by_package,
|
|
76
|
+
compact=not args.no_compact,
|
|
77
|
+
files=files_filter,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
result = {"status": "ok", "files": len(entries),
|
|
81
|
+
"methods": sum(len(e.get("methods", [])) for e in entries),
|
|
82
|
+
"classes": sum(len(e.get("classes", [])) for e in entries)}
|
|
83
|
+
if doc_filter:
|
|
84
|
+
result["doc_filter"] = doc_filter
|
|
85
|
+
|
|
86
|
+
if args.summary and output_dir:
|
|
87
|
+
result["summary"] = str(output_dir / ".meta" / "skeleton-summary.json")
|
|
88
|
+
|
|
89
|
+
if not output_dir:
|
|
90
|
+
# No output dir: write to default location instead of flooding stdout
|
|
91
|
+
default_out = repo_path / ".meta" / "skeleton"
|
|
92
|
+
default_out.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
skel_file = default_out / "skeleton.json"
|
|
94
|
+
skel_file.write_text(json.dumps(entries, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
95
|
+
result["output"] = str(skel_file)
|
|
96
|
+
print(f"Extracted to: {skel_file}")
|
|
97
|
+
print(json.dumps(result, ensure_ascii=False), file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_query(args: argparse.Namespace) -> None:
|
|
101
|
+
"""Query skeleton data."""
|
|
102
|
+
from core.skeleton.query import load_skeleton, stats, high_methods, file_list, method_count, search
|
|
103
|
+
|
|
104
|
+
path = Path(args.path)
|
|
105
|
+
entries = load_skeleton(path)
|
|
106
|
+
|
|
107
|
+
if args.subcmd == "stats":
|
|
108
|
+
result = stats(entries)
|
|
109
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
110
|
+
elif args.subcmd == "high-methods":
|
|
111
|
+
methods = high_methods(entries)
|
|
112
|
+
for m in methods[:20]:
|
|
113
|
+
print(f"{m['file']} {m['method']} ({m['line_count']} lines)")
|
|
114
|
+
if len(methods) > 20:
|
|
115
|
+
print(f" ... and {len(methods) - 20} more")
|
|
116
|
+
print(json.dumps({"status": "ok", "count": len(methods)}, ensure_ascii=False))
|
|
117
|
+
elif args.subcmd == "file-list":
|
|
118
|
+
files = file_list(entries)
|
|
119
|
+
print(f"Total files: {len(files)}")
|
|
120
|
+
for f in files[:30]:
|
|
121
|
+
print(f" {f}")
|
|
122
|
+
if len(files) > 30:
|
|
123
|
+
print(f" ... and {len(files) - 30} more")
|
|
124
|
+
print(json.dumps({"status": "ok", "files": len(files)}, ensure_ascii=False), file=sys.stderr)
|
|
125
|
+
elif args.subcmd == "method-count":
|
|
126
|
+
count = method_count(entries)
|
|
127
|
+
print(json.dumps({"status": "ok", "methods": count}, ensure_ascii=False))
|
|
128
|
+
elif args.subcmd == "search":
|
|
129
|
+
results = search(entries, args.query)
|
|
130
|
+
for r in results[:20]:
|
|
131
|
+
print(f"[{r['type']}] {r['file']}:{r['line']} {r['name']}")
|
|
132
|
+
if len(results) > 20:
|
|
133
|
+
print(f" ... and {len(results) - 20} more")
|
|
134
|
+
print(json.dumps({"status": "ok", "matches": len(results)}, ensure_ascii=False), file=sys.stderr)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_file_list(args: argparse.Namespace) -> None:
|
|
138
|
+
"""Extract file list for a doc type."""
|
|
139
|
+
from core.skeleton.file_list import load_skeleton, extract_file_list, check_coverage
|
|
140
|
+
from core.preset import load_preset
|
|
141
|
+
|
|
142
|
+
preset = load_preset(args.preset)
|
|
143
|
+
module_dir = Path(args.module_dir) if args.module_dir else None
|
|
144
|
+
skeleton_path = Path(args.skeleton) if args.skeleton else None
|
|
145
|
+
|
|
146
|
+
# Load skeleton
|
|
147
|
+
if skeleton_path:
|
|
148
|
+
from core.skeleton.query import load_skeleton as ql
|
|
149
|
+
entries = ql(skeleton_path)
|
|
150
|
+
elif module_dir:
|
|
151
|
+
entries = load_skeleton(module_dir)
|
|
152
|
+
else:
|
|
153
|
+
print("Error: --skeleton or --module-dir required", file=sys.stderr)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
|
|
156
|
+
source_cache = Path(args.source_cache) if args.source_cache else Path(".")
|
|
157
|
+
|
|
158
|
+
if args.coverage_check:
|
|
159
|
+
from core.paths import file_list_dir
|
|
160
|
+
fl_dir = file_list_dir(module_dir) if module_dir else None
|
|
161
|
+
report = check_coverage(entries, preset, fl_dir)
|
|
162
|
+
print(f"Coverage: {report.coverage_pct:.1f}% ({report.covered}/{report.total_files})")
|
|
163
|
+
if report.uncovered_files:
|
|
164
|
+
print(f"Uncovered ({report.uncovered_count}):")
|
|
165
|
+
for f in report.uncovered_files[:20]:
|
|
166
|
+
print(f" {f}")
|
|
167
|
+
output = args.output
|
|
168
|
+
if output:
|
|
169
|
+
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
Path(output).write_text(
|
|
171
|
+
"\n".join(report.uncovered_files), encoding="utf-8"
|
|
172
|
+
)
|
|
173
|
+
print(json.dumps({"status": "ok", "coverage_pct": round(report.coverage_pct, 1),
|
|
174
|
+
"uncovered": report.uncovered_count}, ensure_ascii=False), file=sys.stderr)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
files = extract_file_list(entries, preset, args.doc_type, source_cache)
|
|
178
|
+
if args.output:
|
|
179
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
Path(args.output).write_text("\n".join(files) + "\n", encoding="utf-8")
|
|
181
|
+
print(f"Written {len(files)} files to: {args.output}")
|
|
182
|
+
elif module_dir and args.doc_type:
|
|
183
|
+
from core.paths import file_list_dir
|
|
184
|
+
fl_dir = file_list_dir(module_dir)
|
|
185
|
+
fl_dir.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
out_path = fl_dir / f"{args.doc_type}.txt"
|
|
187
|
+
out_path.write_text("\n".join(files) + "\n", encoding="utf-8")
|
|
188
|
+
print(f"Written {len(files)} files to: {out_path}")
|
|
189
|
+
else:
|
|
190
|
+
for f in files:
|
|
191
|
+
print(f)
|
|
192
|
+
print(json.dumps({"status": "ok", "files": len(files)}, ensure_ascii=False), file=sys.stderr)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_metadata(args: argparse.Namespace) -> None:
|
|
196
|
+
"""Pre-generate global metadata."""
|
|
197
|
+
from core.skeleton.metadata import pregenerate
|
|
198
|
+
|
|
199
|
+
module_dir = Path(args.module_dir)
|
|
200
|
+
output = pregenerate(module_dir, module_name=args.module_name or module_dir.name)
|
|
201
|
+
print(f"Generated: {output}")
|
|
202
|
+
print(json.dumps({"status": "ok", "output": str(output)}, ensure_ascii=False), file=sys.stderr)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_dispatch_preview(args: argparse.Namespace) -> None:
|
|
206
|
+
"""Generate dispatch plan preview and write .meta/dispatch-plan.md + dispatch-tasks.json."""
|
|
207
|
+
from core.config import load_config
|
|
208
|
+
from core.preset import load_preset
|
|
209
|
+
from core.skeleton.dispatch import compute_dispatch_plan
|
|
210
|
+
from core.skeleton.dispatch_render import render_markdown, plan_to_tasks
|
|
211
|
+
|
|
212
|
+
config = load_config(Path(args.config) if args.config else None)
|
|
213
|
+
kb_config = config.get_kb(args.kb)
|
|
214
|
+
preset_name = kb_config.get("preset", "generic")
|
|
215
|
+
preset = load_preset(preset_name)
|
|
216
|
+
|
|
217
|
+
knowledge_dir = Path(kb_config["knowledge_dir"])
|
|
218
|
+
source = kb_config.get("source", {})
|
|
219
|
+
cache_dir = Path(source.get("cache_dir", "./.source-cache"))
|
|
220
|
+
|
|
221
|
+
module_name = args.module
|
|
222
|
+
module_dir = knowledge_dir / module_name
|
|
223
|
+
source_cache = cache_dir / module_name
|
|
224
|
+
|
|
225
|
+
# Resolve module_type from kb-project.yaml repos config
|
|
226
|
+
module_type = "service"
|
|
227
|
+
repos = source.get("repos", [])
|
|
228
|
+
for repo in repos:
|
|
229
|
+
if repo.get("name") == module_name:
|
|
230
|
+
module_type = repo.get("type", "service")
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
mode = args.mode or "readwrite"
|
|
234
|
+
|
|
235
|
+
plan = compute_dispatch_plan(
|
|
236
|
+
preset=preset, module_dir=module_dir, source_cache=source_cache,
|
|
237
|
+
mode=mode, module_name=module_name,
|
|
238
|
+
module_type=module_type,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
markdown = render_markdown(plan, mode=mode)
|
|
242
|
+
|
|
243
|
+
# Write dispatch-plan.md and dispatch-tasks.json to .meta/
|
|
244
|
+
meta_dir = module_dir / ".meta"
|
|
245
|
+
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
(meta_dir / "dispatch-plan.md").write_text(markdown, encoding="utf-8")
|
|
247
|
+
|
|
248
|
+
tasks = plan_to_tasks(
|
|
249
|
+
plan=plan, kb_name=args.kb, preset_name=preset_name,
|
|
250
|
+
preset=preset, knowledge_dir=knowledge_dir, mode=mode,
|
|
251
|
+
)
|
|
252
|
+
(meta_dir / "dispatch-tasks.json").write_text(
|
|
253
|
+
json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Write shard file lists for split entries
|
|
257
|
+
from core.skeleton.dispatch_render import write_shard_files
|
|
258
|
+
write_shard_files(plan, module_dir)
|
|
259
|
+
|
|
260
|
+
# Print compact summary to stdout (full plan is in dispatch-plan.md)
|
|
261
|
+
print(f"Dispatch plan: {len(plan.entries)} doc types, {plan.total_shards()} shards (mode={mode})")
|
|
262
|
+
print(f" Written to: {meta_dir / 'dispatch-plan.md'}")
|
|
263
|
+
print(f" Tasks JSON: {meta_dir / 'dispatch-tasks.json'}")
|
|
264
|
+
for e in plan.entries:
|
|
265
|
+
shards_str = f" x{e.split_count}" if e.split_count > 1 else ""
|
|
266
|
+
print(f" - {e.doc_type} ({e.file_count} files, {e.total_lines} lines){shards_str}")
|
|
267
|
+
|
|
268
|
+
print(json.dumps({"status": "ok", "entries": len(plan.entries),
|
|
269
|
+
"total_shards": plan.total_shards()}, ensure_ascii=False), file=sys.stderr)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_module_dag(args: argparse.Namespace) -> None:
|
|
273
|
+
"""Build and display module dependency DAG."""
|
|
274
|
+
from core.skeleton.module_dag import build_module_dag, topo_sort_modules, get_generation_layers
|
|
275
|
+
|
|
276
|
+
source_cache = Path(args.source_cache)
|
|
277
|
+
modules = args.modules.split(",")
|
|
278
|
+
|
|
279
|
+
dag = build_module_dag(source_cache, modules)
|
|
280
|
+
order = topo_sort_modules(dag)
|
|
281
|
+
layers = get_generation_layers(dag)
|
|
282
|
+
|
|
283
|
+
print("Module generation order (dependencies first):")
|
|
284
|
+
for i, name in enumerate(order, 1):
|
|
285
|
+
deps = dag.edges.get(name, set())
|
|
286
|
+
dep_str = f" (depends on: {', '.join(sorted(deps))})" if deps else ""
|
|
287
|
+
print(f" {i}. {name}{dep_str}")
|
|
288
|
+
|
|
289
|
+
print(f"\nParallel layers:")
|
|
290
|
+
for i, layer in enumerate(layers, 1):
|
|
291
|
+
print(f" Layer {i}: {', '.join(layer)}")
|
|
292
|
+
|
|
293
|
+
print(json.dumps({"status": "ok", "modules": len(modules),
|
|
294
|
+
"order": order, "layers": layers}, ensure_ascii=False), file=sys.stderr)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_post_merge(args: argparse.Namespace) -> None:
|
|
298
|
+
"""Run post-merge refinement on all docs in a module directory."""
|
|
299
|
+
from core.skeleton.post_merge import refine_merged_doc
|
|
300
|
+
|
|
301
|
+
module_dir = Path(args.module_dir)
|
|
302
|
+
if not module_dir.is_dir():
|
|
303
|
+
print(f"Error: directory not found: {module_dir}", file=sys.stderr)
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
|
|
306
|
+
results = {"refined": 0, "unchanged": 0, "errors": []}
|
|
307
|
+
for md in sorted(module_dir.glob("*.md")):
|
|
308
|
+
if md.name.startswith(".") or md.name.lower() == "readme.md":
|
|
309
|
+
continue
|
|
310
|
+
try:
|
|
311
|
+
result = refine_merged_doc(md)
|
|
312
|
+
if result.changed:
|
|
313
|
+
result.apply()
|
|
314
|
+
results["refined"] += 1
|
|
315
|
+
print(f" ✅ {md.name}: {result.summary()}")
|
|
316
|
+
else:
|
|
317
|
+
results["unchanged"] += 1
|
|
318
|
+
print(f" — {md.name}: No fixes needed")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
results["errors"].append(f"{md.name}: {e}")
|
|
321
|
+
print(f" ❌ {md.name}: {e}")
|
|
322
|
+
|
|
323
|
+
print(json.dumps({"status": "ok", "refined": results["refined"],
|
|
324
|
+
"unchanged": results["unchanged"],
|
|
325
|
+
"errors": len(results["errors"])}, ensure_ascii=False), file=sys.stderr)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def cmd_audit(args: argparse.Namespace) -> None:
|
|
329
|
+
"""Run low-confidence classification audit on skeleton entries."""
|
|
330
|
+
from core.skeleton.classify import find_suspicious_files
|
|
331
|
+
from core.preset import load_preset
|
|
332
|
+
from core.skeleton.query import load_skeleton
|
|
333
|
+
|
|
334
|
+
preset = load_preset(args.preset)
|
|
335
|
+
skeleton_path = Path(args.skeleton)
|
|
336
|
+
entries = load_skeleton(skeleton_path)
|
|
337
|
+
threshold = args.threshold
|
|
338
|
+
|
|
339
|
+
suspicious = find_suspicious_files(entries, preset, threshold=threshold)
|
|
340
|
+
|
|
341
|
+
if suspicious:
|
|
342
|
+
print(f"⚠️ Found {len(suspicious)} low-confidence files (threshold < {threshold}):\n")
|
|
343
|
+
print("| File | Category | Confidence | Match Reason | Suspicious Signal |")
|
|
344
|
+
print("|------|------|--------|---------|---------|")
|
|
345
|
+
for entry in suspicious:
|
|
346
|
+
cats = ", ".join(entry.categories) if entry.categories else "—"
|
|
347
|
+
reasons = "; ".join(entry.match_reasons[:2]) if entry.match_reasons else "—"
|
|
348
|
+
signals = "; ".join(entry.suspicious_signals[:2]) if entry.suspicious_signals else "—"
|
|
349
|
+
print(f"| {entry.file} | {cats} | {entry.confidence:.2f} | {reasons} | {signals} |")
|
|
350
|
+
else:
|
|
351
|
+
print(f"✅ No low-confidence files (threshold < {threshold})")
|
|
352
|
+
|
|
353
|
+
print(json.dumps({"status": "ok", "suspicious": len(suspicious),
|
|
354
|
+
"threshold": threshold}, ensure_ascii=False), file=sys.stderr)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_record_feedback(args: argparse.Namespace) -> None:
|
|
358
|
+
"""Record split execution feedback for adaptive tuning."""
|
|
359
|
+
from core.skeleton.split_feedback import SplitRecord, record_split_result
|
|
360
|
+
|
|
361
|
+
module_dir = Path(args.module_dir)
|
|
362
|
+
record = SplitRecord(
|
|
363
|
+
doc_type=args.doc_type,
|
|
364
|
+
strategy=args.strategy,
|
|
365
|
+
resolution=args.resolution,
|
|
366
|
+
n_splits=args.n_splits,
|
|
367
|
+
coverage_score=args.coverage_score,
|
|
368
|
+
quality_score=args.quality_score,
|
|
369
|
+
)
|
|
370
|
+
record_split_result(module_dir, record)
|
|
371
|
+
print(f"✅ Feedback recorded: {args.doc_type} ({args.strategy}, {args.n_splits} splits, "
|
|
372
|
+
f"score={record.composite_score:.3f})")
|
|
373
|
+
print(json.dumps({"status": "ok", "doc_type": args.doc_type,
|
|
374
|
+
"composite_score": record.composite_score}, ensure_ascii=False), file=sys.stderr)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _find_common_prefix(strings: list[str]) -> str:
|
|
378
|
+
"""Find the longest common prefix of a list of dot-separated package names."""
|
|
379
|
+
if not strings:
|
|
380
|
+
return ""
|
|
381
|
+
if len(strings) == 1:
|
|
382
|
+
parts = strings[0].split(".")
|
|
383
|
+
return ".".join(parts[:-1]) + "." if len(parts) > 1 else ""
|
|
384
|
+
prefix = strings[0]
|
|
385
|
+
for s in strings[1:]:
|
|
386
|
+
while not s.startswith(prefix):
|
|
387
|
+
if "." in prefix:
|
|
388
|
+
prefix = prefix[:prefix.rfind(".") + 1]
|
|
389
|
+
else:
|
|
390
|
+
prefix = ""
|
|
391
|
+
break
|
|
392
|
+
return prefix
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def cmd_split_files(args: argparse.Namespace) -> None:
|
|
396
|
+
"""Generate shard file lists for a doc type that needs splitting.
|
|
397
|
+
|
|
398
|
+
Reads the full file list for a doc-type, runs plan_splits() to compute
|
|
399
|
+
the split plan (community detection → package → simple), and writes
|
|
400
|
+
per-shard file lists to .meta/file-lists/{doc_type}-{shard_name}.txt.
|
|
401
|
+
|
|
402
|
+
This is the missing step between dispatch-preview (which only computes
|
|
403
|
+
split_count) and prompt rendering (which needs per-shard file lists).
|
|
404
|
+
"""
|
|
405
|
+
from core.config import load_config
|
|
406
|
+
from core.preset import load_preset, get_doc_type_mapping
|
|
407
|
+
from core.skeleton.split import SplitConfig
|
|
408
|
+
from core.skeleton.split_plan import plan_splits
|
|
409
|
+
from core.skeleton.dispatch import get_file_list_with_stats
|
|
410
|
+
from core.skeleton.file_list import load_skeleton as load_skeleton_entries
|
|
411
|
+
from core.paths import file_list_dir
|
|
412
|
+
|
|
413
|
+
config = load_config(Path(args.config) if args.config else None)
|
|
414
|
+
kb_config = config.get_kb(args.kb)
|
|
415
|
+
preset_name = kb_config.get("preset", "generic")
|
|
416
|
+
preset = load_preset(preset_name)
|
|
417
|
+
|
|
418
|
+
knowledge_dir = Path(kb_config["knowledge_dir"])
|
|
419
|
+
source = kb_config.get("source", {})
|
|
420
|
+
cache_dir = Path(source.get("cache_dir", "./.source-cache"))
|
|
421
|
+
|
|
422
|
+
module_name = args.module
|
|
423
|
+
module_dir = knowledge_dir / module_name
|
|
424
|
+
|
|
425
|
+
# Resolve source_cache (monorepo vs multi-repo)
|
|
426
|
+
if source.get("structure") == "monorepo":
|
|
427
|
+
repo_name = source.get("repo_name", "repo")
|
|
428
|
+
module_cfg = next((m for m in source.get("modules", []) if m["name"] == module_name), {})
|
|
429
|
+
module_path = module_cfg.get("path", module_name)
|
|
430
|
+
source_cache = cache_dir / repo_name / module_path
|
|
431
|
+
else:
|
|
432
|
+
source_cache = cache_dir / module_name
|
|
433
|
+
|
|
434
|
+
doc_type = args.doc_type
|
|
435
|
+
mode = args.mode or "readwrite"
|
|
436
|
+
|
|
437
|
+
dt_mapping = get_doc_type_mapping(preset)
|
|
438
|
+
split_config = SplitConfig.from_preset(preset, mode=mode)
|
|
439
|
+
|
|
440
|
+
# Get file list with stats (path, lines, bytes)
|
|
441
|
+
files = get_file_list_with_stats(module_dir, doc_type, source_cache, dt_mapping)
|
|
442
|
+
if not files:
|
|
443
|
+
print(f"❌ No files found for doc-type '{doc_type}'", file=sys.stderr)
|
|
444
|
+
print(json.dumps({"status": "error", "message": f"No files for {doc_type}"},
|
|
445
|
+
ensure_ascii=False), file=sys.stderr)
|
|
446
|
+
sys.exit(1)
|
|
447
|
+
|
|
448
|
+
# Load skeleton entries for dependency analysis (community detection)
|
|
449
|
+
entries = load_skeleton_entries(module_dir)
|
|
450
|
+
|
|
451
|
+
# Run split planning
|
|
452
|
+
plan = plan_splits(
|
|
453
|
+
entries=entries,
|
|
454
|
+
file_list=files,
|
|
455
|
+
split_config=split_config,
|
|
456
|
+
doc_type=doc_type,
|
|
457
|
+
module_dir=module_dir,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if plan.recommended_agents <= 1:
|
|
461
|
+
print(f"ℹ️ No split needed for {doc_type} (strategy: {plan.strategy})")
|
|
462
|
+
print(json.dumps({"status": "ok", "splits": 1, "strategy": plan.strategy,
|
|
463
|
+
"message": "no split needed"}, ensure_ascii=False), file=sys.stderr)
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
# Write shard file lists (clean old shards first)
|
|
467
|
+
fl_dir = file_list_dir(module_dir)
|
|
468
|
+
fl_dir.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
|
|
470
|
+
# Remove old shard files for this doc_type
|
|
471
|
+
for old_file in fl_dir.glob(f"{doc_type}-*.txt"):
|
|
472
|
+
old_file.unlink()
|
|
473
|
+
|
|
474
|
+
shard_info = []
|
|
475
|
+
for i, split in enumerate(plan.splits, 1):
|
|
476
|
+
shard_name = split.get("name", f"shard-{i}")
|
|
477
|
+
# If name looks like a path or is too long, derive from package info
|
|
478
|
+
if len(shard_name) > 40 or "/" in shard_name or "\\" in shard_name or ":" in shard_name:
|
|
479
|
+
# Use the last meaningful package segment from files in this split
|
|
480
|
+
pkgs = split.get("packages", [])
|
|
481
|
+
if pkgs:
|
|
482
|
+
# Find the distinguishing part of the package
|
|
483
|
+
common_prefix = _find_common_prefix(pkgs)
|
|
484
|
+
unique_parts = set()
|
|
485
|
+
for pkg in pkgs:
|
|
486
|
+
suffix = pkg[len(common_prefix):].strip(".")
|
|
487
|
+
if suffix:
|
|
488
|
+
unique_parts.add(suffix.split(".")[0])
|
|
489
|
+
if unique_parts:
|
|
490
|
+
shard_name = "-".join(sorted(unique_parts)[:3])
|
|
491
|
+
else:
|
|
492
|
+
shard_name = pkgs[0].split(".")[-1] if pkgs[0] else f"shard-{i}"
|
|
493
|
+
else:
|
|
494
|
+
shard_name = f"shard-{i}"
|
|
495
|
+
# Sanitize shard name for filename
|
|
496
|
+
safe_name = shard_name.replace("/", "_").replace("\\", "_").replace(" ", "-").replace(":", "")
|
|
497
|
+
# Truncate if still too long
|
|
498
|
+
if len(safe_name) > 30:
|
|
499
|
+
safe_name = safe_name[:30]
|
|
500
|
+
shard_file = fl_dir / f"{doc_type}-{safe_name}.txt"
|
|
501
|
+
|
|
502
|
+
# Write file paths (use rel_path if available, else name)
|
|
503
|
+
shard_files = split.get("files", [])
|
|
504
|
+
# Resolve back to relative paths from the original file list
|
|
505
|
+
name_to_rel = {f.get("name", ""): f.get("rel_path", f.get("name", "")) for f in files}
|
|
506
|
+
lines_out = []
|
|
507
|
+
for fname in shard_files:
|
|
508
|
+
rel = name_to_rel.get(fname, fname)
|
|
509
|
+
lines_out.append(rel)
|
|
510
|
+
|
|
511
|
+
shard_file.write_text("\n".join(lines_out) + "\n", encoding="utf-8")
|
|
512
|
+
shard_info.append({
|
|
513
|
+
"shard_name": safe_name,
|
|
514
|
+
"file": str(shard_file),
|
|
515
|
+
"file_count": len(lines_out),
|
|
516
|
+
"lines": split.get("lines", 0),
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
# Print summary
|
|
520
|
+
print(f"✅ Split {doc_type} into {len(plan.splits)} shards (strategy: {plan.strategy})")
|
|
521
|
+
print()
|
|
522
|
+
print(f"| # | Shard Name | Files | Lines | File List |")
|
|
523
|
+
print(f"|---|-----------|-------|-------|-----------|")
|
|
524
|
+
for i, info in enumerate(shard_info, 1):
|
|
525
|
+
rel_file = Path(info["file"]).relative_to(Path.cwd()) if Path(info["file"]).is_absolute() else info["file"]
|
|
526
|
+
print(f"| {i} | {info['shard_name']} | {info['file_count']} | {info['lines']} | {rel_file} |")
|
|
527
|
+
|
|
528
|
+
if plan.warnings:
|
|
529
|
+
print(f"\n⚠️ Warnings:")
|
|
530
|
+
for w in plan.warnings:
|
|
531
|
+
print(f" - {w}")
|
|
532
|
+
|
|
533
|
+
print(json.dumps({
|
|
534
|
+
"status": "ok",
|
|
535
|
+
"doc_type": doc_type,
|
|
536
|
+
"splits": len(plan.splits),
|
|
537
|
+
"strategy": plan.strategy,
|
|
538
|
+
"shards": shard_info,
|
|
539
|
+
}, ensure_ascii=False), file=sys.stderr)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cmd_split_apply(args: argparse.Namespace) -> None:
|
|
543
|
+
"""Validate and apply Agent-provided grouping result.
|
|
544
|
+
|
|
545
|
+
Reads the Agent's grouping JSON, validates against constraints from the
|
|
546
|
+
original grouping request, and writes shard file lists to .meta/shards/.
|
|
547
|
+
"""
|
|
548
|
+
import json
|
|
549
|
+
|
|
550
|
+
module_dir = Path(args.module_dir)
|
|
551
|
+
doc_type = args.doc_type
|
|
552
|
+
groups_path = Path(args.groups)
|
|
553
|
+
|
|
554
|
+
if not groups_path.exists():
|
|
555
|
+
print(json.dumps({"status": "error", "message": f"Groups file not found: {groups_path}"}))
|
|
556
|
+
sys.exit(1)
|
|
557
|
+
|
|
558
|
+
# Load grouping request for constraints
|
|
559
|
+
request_path = module_dir / ".meta" / "split-requests" / f"{doc_type}-grouping-request.json"
|
|
560
|
+
if not request_path.exists():
|
|
561
|
+
print(json.dumps({"status": "error", "message": f"No grouping request found: {request_path}"}))
|
|
562
|
+
sys.exit(1)
|
|
563
|
+
|
|
564
|
+
request = json.loads(request_path.read_text(encoding="utf-8"))
|
|
565
|
+
constraints = request["constraints"]
|
|
566
|
+
all_files = {f["name"] for f in request["files"]}
|
|
567
|
+
file_lookup = {f["name"]: f for f in request["files"]}
|
|
568
|
+
|
|
569
|
+
# Load Agent's grouping result
|
|
570
|
+
groups = json.loads(groups_path.read_text(encoding="utf-8"))
|
|
571
|
+
if not isinstance(groups, list):
|
|
572
|
+
print(json.dumps({"status": "error", "message": "Groups must be a JSON array"}))
|
|
573
|
+
sys.exit(1)
|
|
574
|
+
|
|
575
|
+
# Validate
|
|
576
|
+
errors: list[str] = []
|
|
577
|
+
assigned: set[str] = set()
|
|
578
|
+
group_stats: list[dict] = []
|
|
579
|
+
|
|
580
|
+
for i, g in enumerate(groups):
|
|
581
|
+
name = g.get("name", f"group-{i+1}")
|
|
582
|
+
files = g.get("files", [])
|
|
583
|
+
|
|
584
|
+
# Resolve file names (Agent may use short names)
|
|
585
|
+
resolved: list[str] = []
|
|
586
|
+
for fname in files:
|
|
587
|
+
if fname in all_files:
|
|
588
|
+
resolved.append(fname)
|
|
589
|
+
else:
|
|
590
|
+
# Try suffix match
|
|
591
|
+
matches = [f for f in all_files if f.endswith(fname) or f == fname]
|
|
592
|
+
if matches:
|
|
593
|
+
resolved.append(matches[0])
|
|
594
|
+
else:
|
|
595
|
+
errors.append(f"Group '{name}': unknown file '{fname}'")
|
|
596
|
+
|
|
597
|
+
# Check duplicates
|
|
598
|
+
for f in resolved:
|
|
599
|
+
if f in assigned:
|
|
600
|
+
errors.append(f"Group '{name}': duplicate file '{f}'")
|
|
601
|
+
assigned.add(f)
|
|
602
|
+
|
|
603
|
+
lines = sum(file_lookup.get(f, {}).get("lines", 0) for f in resolved)
|
|
604
|
+
group_stats.append({"name": name, "files": resolved, "file_count": len(resolved), "lines": lines})
|
|
605
|
+
|
|
606
|
+
# Check all files assigned
|
|
607
|
+
missing = all_files - assigned
|
|
608
|
+
if missing and constraints.get("all_files_must_be_assigned", True):
|
|
609
|
+
errors.append(f"{len(missing)} files not assigned: {sorted(missing)[:5]}...")
|
|
610
|
+
|
|
611
|
+
# Check per-group constraints
|
|
612
|
+
max_files = constraints.get("max_files_per_group", 80)
|
|
613
|
+
max_lines = constraints.get("max_lines_per_group", 10000)
|
|
614
|
+
max_ratio = constraints.get("max_imbalance_ratio", 3.0)
|
|
615
|
+
|
|
616
|
+
for gs in group_stats:
|
|
617
|
+
if gs["file_count"] > max_files:
|
|
618
|
+
errors.append(f"Group '{gs['name']}': {gs['file_count']} files > max {max_files}")
|
|
619
|
+
if gs["lines"] > max_lines:
|
|
620
|
+
errors.append(f"Group '{gs['name']}': {gs['lines']} lines > max {max_lines}")
|
|
621
|
+
|
|
622
|
+
# Check imbalance
|
|
623
|
+
if group_stats:
|
|
624
|
+
line_counts = [gs["lines"] for gs in group_stats if gs["lines"] > 0]
|
|
625
|
+
if line_counts and max(line_counts) / max(min(line_counts), 1) > max_ratio:
|
|
626
|
+
errors.append(f"Imbalance ratio {max(line_counts)/max(min(line_counts),1):.1f}x > {max_ratio}x")
|
|
627
|
+
|
|
628
|
+
if errors:
|
|
629
|
+
print(json.dumps({"status": "error", "errors": errors}, ensure_ascii=False))
|
|
630
|
+
sys.exit(1)
|
|
631
|
+
|
|
632
|
+
# Write shard files
|
|
633
|
+
shards_dir = module_dir / ".meta" / "shards"
|
|
634
|
+
shards_dir.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
|
|
636
|
+
for i, gs in enumerate(group_stats, 1):
|
|
637
|
+
shard_path = shards_dir / f"{doc_type}-shard-{i:02d}.txt"
|
|
638
|
+
# Write file paths (relative to source-cache)
|
|
639
|
+
# Look up rel_path from request files
|
|
640
|
+
rel_paths = []
|
|
641
|
+
for fname in gs["files"]:
|
|
642
|
+
entry = file_lookup.get(fname, {})
|
|
643
|
+
rel_paths.append(entry.get("rel_path", fname) if "rel_path" in entry else fname)
|
|
644
|
+
shard_path.write_text("\n".join(rel_paths) + "\n", encoding="utf-8")
|
|
645
|
+
|
|
646
|
+
# Clean up request file
|
|
647
|
+
request_path.unlink(missing_ok=True)
|
|
648
|
+
|
|
649
|
+
print(json.dumps({
|
|
650
|
+
"status": "ok",
|
|
651
|
+
"doc_type": doc_type,
|
|
652
|
+
"shards": len(group_stats),
|
|
653
|
+
"groups": [{"name": gs["name"], "file_count": gs["file_count"], "lines": gs["lines"]} for gs in group_stats],
|
|
654
|
+
}, ensure_ascii=False))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def cmd_diff_doc(args: argparse.Namespace) -> None:
|
|
658
|
+
"""Compare document against skeleton to find inconsistencies."""
|
|
659
|
+
from core.skeleton.cmd_diff_doc import cmd_diff_doc as _impl
|
|
660
|
+
_impl(args)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def cmd_lock(args: argparse.Namespace) -> None:
|
|
664
|
+
"""Unified lock management for both Agent and CLI modes."""
|
|
665
|
+
from core.skeleton.cmd_lock import cmd_lock as _impl
|
|
666
|
+
_impl(args)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _is_stale_lock(lock_meta_path: Path) -> bool:
|
|
670
|
+
"""Check if a lock is stale (>30 minutes old). Delegated to cmd_lock module."""
|
|
671
|
+
from core.skeleton.cmd_lock import _is_stale_lock as _impl
|
|
672
|
+
return _impl(lock_meta_path)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def cmd_anchor_fix(args: argparse.Namespace) -> None:
|
|
676
|
+
"""Fix broken cross-document anchor links."""
|
|
677
|
+
from core.skeleton.cmd_anchor_fix import cmd_anchor_fix as _impl
|
|
678
|
+
_impl(args)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def cmd_merge_delta(args: argparse.Namespace) -> None:
|
|
682
|
+
"""Merge skeleton delta into existing skeleton."""
|
|
683
|
+
from core.skeleton.cmd_merge_delta import cmd_merge_delta as _impl
|
|
684
|
+
_impl(args)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def cmd_stale_files(args: argparse.Namespace) -> None:
|
|
688
|
+
"""Detect stale/orphaned documentation files not in the current dispatch plan."""
|
|
689
|
+
from core.config import load_config
|
|
690
|
+
from core.preset import load_preset
|
|
691
|
+
from core.skeleton.dispatch import compute_dispatch_plan
|
|
692
|
+
|
|
693
|
+
config = load_config(Path(args.config) if args.config else None)
|
|
694
|
+
kb_config = config.get_kb(args.kb)
|
|
695
|
+
preset_name = kb_config.get("preset", "generic")
|
|
696
|
+
preset = load_preset(preset_name)
|
|
697
|
+
|
|
698
|
+
knowledge_dir = Path(kb_config["knowledge_dir"])
|
|
699
|
+
source = kb_config.get("source", {})
|
|
700
|
+
cache_dir = Path(source.get("cache_dir", "./.source-cache"))
|
|
701
|
+
|
|
702
|
+
module_name = args.module
|
|
703
|
+
module_dir = knowledge_dir / module_name
|
|
704
|
+
|
|
705
|
+
# Resolve source_cache (monorepo vs multi-repo)
|
|
706
|
+
if source.get("structure") == "monorepo":
|
|
707
|
+
repo_name = source.get("repo_name", "repo")
|
|
708
|
+
module_cfg = next((m for m in source.get("modules", []) if m["name"] == module_name), {})
|
|
709
|
+
module_path = module_cfg.get("path", module_name)
|
|
710
|
+
source_cache = cache_dir / repo_name / module_path
|
|
711
|
+
else:
|
|
712
|
+
source_cache = cache_dir / module_name
|
|
713
|
+
|
|
714
|
+
# Resolve module_type
|
|
715
|
+
module_type = "service"
|
|
716
|
+
repos = source.get("repos", [])
|
|
717
|
+
for repo in repos:
|
|
718
|
+
if repo.get("name") == module_name:
|
|
719
|
+
module_type = repo.get("type", "service")
|
|
720
|
+
break
|
|
721
|
+
|
|
722
|
+
# Get dispatch plan to know expected files
|
|
723
|
+
plan = compute_dispatch_plan(
|
|
724
|
+
preset=preset, module_dir=module_dir, source_cache=source_cache,
|
|
725
|
+
mode="readwrite", module_name=module_name, module_type=module_type,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
expected_files = {e.doc_filename for e in plan.entries}
|
|
729
|
+
# Add all non-conditional (always-generated) doc types from preset
|
|
730
|
+
doc_types_cfg = preset.get("doc_types", {})
|
|
731
|
+
for dt_key, dt_config in doc_types_cfg.items():
|
|
732
|
+
if isinstance(dt_config, dict) and not dt_config.get("conditional", True):
|
|
733
|
+
expected_files.add(dt_config.get("filename", f"{dt_key}.md"))
|
|
734
|
+
|
|
735
|
+
# Scan existing .md files
|
|
736
|
+
existing_files: list[Path] = []
|
|
737
|
+
if module_dir.is_dir():
|
|
738
|
+
existing_files = [f for f in module_dir.glob("*.md")
|
|
739
|
+
if not f.name.startswith(".")
|
|
740
|
+
and f.name.lower() != "readme.md"]
|
|
741
|
+
|
|
742
|
+
# Find stale files
|
|
743
|
+
stale: list[dict] = []
|
|
744
|
+
from core.preset import get_doc_type_mapping
|
|
745
|
+
doc_type_filenames = set(get_doc_type_mapping(preset).values())
|
|
746
|
+
module_types_config = preset.get("module_types", {})
|
|
747
|
+
type_config = module_types_config.get(module_type, {})
|
|
748
|
+
skip_docs = set(type_config.get("skip", []))
|
|
749
|
+
for f in existing_files:
|
|
750
|
+
if f.name not in expected_files:
|
|
751
|
+
import time
|
|
752
|
+
mtime = f.stat().st_mtime
|
|
753
|
+
mtime_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
|
|
754
|
+
size_kb = round(f.stat().st_size / 1024, 1)
|
|
755
|
+
|
|
756
|
+
# Determine likely reason
|
|
757
|
+
reason = "Not in current dispatch plan"
|
|
758
|
+
if f.name in skip_docs:
|
|
759
|
+
reason = f"Doc type skipped for {module_type} modules"
|
|
760
|
+
elif f.name in doc_type_filenames:
|
|
761
|
+
reason = "File classification did not trigger this doc"
|
|
762
|
+
|
|
763
|
+
stale.append({
|
|
764
|
+
"file": f.name,
|
|
765
|
+
"size_kb": size_kb,
|
|
766
|
+
"last_modified": mtime_str,
|
|
767
|
+
"reason": reason,
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
# Output
|
|
771
|
+
if stale:
|
|
772
|
+
print(f"⚠️ Detected {len(stale)} stale/orphaned files:\n")
|
|
773
|
+
print("| File | Size | Last Modified | Likely Reason |")
|
|
774
|
+
print("|------|------|---------|---------|")
|
|
775
|
+
for s in stale:
|
|
776
|
+
print(f"| {s['file']} | {s['size_kb']}KB | {s['last_modified']} | {s['reason']} |")
|
|
777
|
+
print("\nRecommended actions:")
|
|
778
|
+
print(" 1. Delete all (recommended: keep directory clean, avoid index pollution)")
|
|
779
|
+
print(" 2. Keep all (files won't be indexed, but remain in directory)")
|
|
780
|
+
print(" 3. Confirm individually")
|
|
781
|
+
else:
|
|
782
|
+
print("✅ No stale files, all documents are in the dispatch plan.")
|
|
783
|
+
|
|
784
|
+
print(json.dumps({"status": "ok", "stale_count": len(stale),
|
|
785
|
+
"stale_files": [s["file"] for s in stale]}, ensure_ascii=False),
|
|
786
|
+
file=sys.stderr)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def main():
|
|
790
|
+
import warnings
|
|
791
|
+
warnings.warn(
|
|
792
|
+
"Use 'source-kb <command>' instead of 'python -m core.skeleton <command>'",
|
|
793
|
+
DeprecationWarning, stacklevel=2,
|
|
794
|
+
)
|
|
795
|
+
print("[DEPRECATED] Use 'source-kb <command>' instead of 'python -m core.skeleton'",
|
|
796
|
+
file=sys.stderr)
|
|
797
|
+
parser = argparse.ArgumentParser(prog="python -m core.skeleton", description="Skeleton tools")
|
|
798
|
+
sub = parser.add_subparsers(dest="command")
|
|
799
|
+
|
|
800
|
+
# extract
|
|
801
|
+
p = sub.add_parser("extract", help="Extract skeleton from repository")
|
|
802
|
+
p.add_argument("--repo", required=True, help="Repository path")
|
|
803
|
+
p.add_argument("--preset", required=True, help="Preset name")
|
|
804
|
+
p.add_argument("--ref", default="HEAD", help="Git reference")
|
|
805
|
+
p.add_argument("--output", help="Output directory")
|
|
806
|
+
p.add_argument("--split-by-package", action="store_true")
|
|
807
|
+
p.add_argument("--no-compact", action="store_true")
|
|
808
|
+
p.add_argument("--summary", action="store_true")
|
|
809
|
+
p.add_argument("--full", action="store_true", help="Full extraction (all files)")
|
|
810
|
+
p.add_argument("--subpath", help="Subdirectory to scope extraction (monorepo module path)")
|
|
811
|
+
p.add_argument("--files", nargs="*", help="Extract only specified file paths (incremental mode)")
|
|
812
|
+
p.add_argument("--doc", help="Extract only files relevant to specified doc type")
|
|
813
|
+
|
|
814
|
+
# query
|
|
815
|
+
p = sub.add_parser("query", help="Query skeleton data")
|
|
816
|
+
p.add_argument("subcmd", choices=["stats", "high-methods", "file-list", "method-count", "search"])
|
|
817
|
+
p.add_argument("path", help="Skeleton JSON path or directory")
|
|
818
|
+
p.add_argument("query", nargs="?", default="", help="Search query (for search subcmd)")
|
|
819
|
+
|
|
820
|
+
# file-list
|
|
821
|
+
p = sub.add_parser("file-list", help="Extract file list for doc type")
|
|
822
|
+
p.add_argument("--skeleton", help="Skeleton JSON path")
|
|
823
|
+
p.add_argument("--skeleton-dir", help="Skeleton shards directory")
|
|
824
|
+
p.add_argument("--module-dir", help="Module directory")
|
|
825
|
+
p.add_argument("--preset", required=True, help="Preset name")
|
|
826
|
+
p.add_argument("--doc-type", help="Document type")
|
|
827
|
+
p.add_argument("--source-cache", help="Source cache path")
|
|
828
|
+
p.add_argument("--output", help="Output file path")
|
|
829
|
+
p.add_argument("--coverage-check", action="store_true")
|
|
830
|
+
p.add_argument("--file-lists-dir", help="Existing file-lists directory")
|
|
831
|
+
|
|
832
|
+
# metadata
|
|
833
|
+
p = sub.add_parser("metadata", help="Pre-generate global metadata")
|
|
834
|
+
p.add_argument("--module-dir", required=True, help="Module directory")
|
|
835
|
+
p.add_argument("--module-name", help="Module name")
|
|
836
|
+
|
|
837
|
+
# dispatch-preview
|
|
838
|
+
p = sub.add_parser("dispatch-preview", help="Generate dispatch plan preview")
|
|
839
|
+
p.add_argument("--config", help="kb-project.yaml path")
|
|
840
|
+
p.add_argument("--kb", required=True, help="Knowledge base name")
|
|
841
|
+
p.add_argument("--module", required=True, help="Module name")
|
|
842
|
+
p.add_argument("--mode", default="readwrite", choices=["readwrite", "output-only"])
|
|
843
|
+
|
|
844
|
+
# module-dag
|
|
845
|
+
p = sub.add_parser("module-dag", help="Build module dependency DAG")
|
|
846
|
+
p.add_argument("--source-cache", required=True, help="Source cache directory")
|
|
847
|
+
p.add_argument("--modules", required=True, help="Comma-separated module names")
|
|
848
|
+
|
|
849
|
+
# stale-files
|
|
850
|
+
p = sub.add_parser("stale-files", help="Detect stale/orphaned docs not in dispatch plan")
|
|
851
|
+
p.add_argument("--config", help="kb-project.yaml path")
|
|
852
|
+
p.add_argument("--kb", required=True, help="Knowledge base name")
|
|
853
|
+
p.add_argument("--module", required=True, help="Module name")
|
|
854
|
+
|
|
855
|
+
# post-merge
|
|
856
|
+
p = sub.add_parser("post-merge", help="Run post-merge refinement (dedup + term consistency + anchors)")
|
|
857
|
+
p.add_argument("--module-dir", required=True, help="Module directory")
|
|
858
|
+
|
|
859
|
+
# audit
|
|
860
|
+
p = sub.add_parser("audit", help="Low-confidence classification audit")
|
|
861
|
+
p.add_argument("--skeleton", required=True, help="Skeleton JSON path")
|
|
862
|
+
p.add_argument("--preset", required=True, help="Preset name")
|
|
863
|
+
p.add_argument("--threshold", type=float, default=0.7, help="Confidence threshold (default: 0.7)")
|
|
864
|
+
|
|
865
|
+
# record-feedback
|
|
866
|
+
p = sub.add_parser("record-feedback", help="Record split execution feedback")
|
|
867
|
+
p.add_argument("--module-dir", required=True, help="Module directory")
|
|
868
|
+
p.add_argument("--doc-type", required=True, help="Document type")
|
|
869
|
+
p.add_argument("--strategy", required=True, choices=["community", "package", "simple", "single"])
|
|
870
|
+
p.add_argument("--resolution", type=float, default=1.0, help="Resolution parameter")
|
|
871
|
+
p.add_argument("--n-splits", type=int, default=1, help="Number of splits")
|
|
872
|
+
p.add_argument("--coverage-score", type=float, default=0.0, help="Coverage score (0-1)")
|
|
873
|
+
p.add_argument("--quality-score", type=float, default=0.0, help="Quality score (0-1)")
|
|
874
|
+
|
|
875
|
+
# split-files
|
|
876
|
+
p = sub.add_parser("split-files", help="Generate shard file lists for a doc type that needs splitting")
|
|
877
|
+
p.add_argument("--config", help="kb-project.yaml path")
|
|
878
|
+
p.add_argument("--kb", required=True, help="Knowledge base name")
|
|
879
|
+
p.add_argument("--module", required=True, help="Module name")
|
|
880
|
+
p.add_argument("--doc-type", required=True, help="Document type to split")
|
|
881
|
+
p.add_argument("--mode", default="readwrite", choices=["readwrite", "output-only"])
|
|
882
|
+
|
|
883
|
+
# split-apply
|
|
884
|
+
p = sub.add_parser("split-apply", help="Validate and apply Agent-provided grouping result")
|
|
885
|
+
p.add_argument("--module-dir", required=True, help="Module knowledge directory")
|
|
886
|
+
p.add_argument("--doc-type", required=True, help="Document type")
|
|
887
|
+
p.add_argument("--groups", required=True, help="Path to groups JSON file (Agent output)")
|
|
888
|
+
|
|
889
|
+
# merge-delta
|
|
890
|
+
p = sub.add_parser("merge-delta", help="Merge skeleton delta into existing skeleton")
|
|
891
|
+
p.add_argument("--delta", required=True, help="Path to .skeleton-delta.json")
|
|
892
|
+
p.add_argument("--target", required=True, help="Target module directory")
|
|
893
|
+
p.add_argument("--dry-run", action="store_true", help="Preview without writing")
|
|
894
|
+
p.add_argument("--no-cleanup", action="store_true", help="Keep delta file after merge")
|
|
895
|
+
|
|
896
|
+
# anchor-fix
|
|
897
|
+
p = sub.add_parser("anchor-fix", help="Fix broken cross-document anchor links")
|
|
898
|
+
p.add_argument("--module-dir", required=True, help="Module documentation directory")
|
|
899
|
+
p.add_argument("--dry-run", action="store_true", help="Report without fixing")
|
|
900
|
+
p.add_argument("--threshold", type=float, default=0.8, help="Fuzzy match threshold (0.0-1.0)")
|
|
901
|
+
|
|
902
|
+
# lock
|
|
903
|
+
p = sub.add_parser("lock", help="Unified lock management (acquire/release/status)")
|
|
904
|
+
p.add_argument("--action", required=True, choices=["acquire", "release", "status"])
|
|
905
|
+
p.add_argument("--dir", required=True, help="Knowledge directory")
|
|
906
|
+
p.add_argument("--operation", help="Operation name (kb-sync|kb-audit)")
|
|
907
|
+
p.add_argument("--timeout", type=int, default=30, help="Timeout in minutes")
|
|
908
|
+
|
|
909
|
+
# diff-doc
|
|
910
|
+
p = sub.add_parser("diff-doc", help="Compare document against skeleton")
|
|
911
|
+
p.add_argument("--doc-path", required=True, help="Path to markdown document")
|
|
912
|
+
p.add_argument("--skeleton-path", required=True, help="Path to skeleton JSON file or directory")
|
|
913
|
+
p.add_argument("--doc-type", help="Document type for specific comparison rules")
|
|
914
|
+
p.add_argument("--output", help="Output JSON path (default: stdout)")
|
|
915
|
+
|
|
916
|
+
args = parser.parse_args()
|
|
917
|
+
if not args.command:
|
|
918
|
+
parser.print_help()
|
|
919
|
+
sys.exit(1)
|
|
920
|
+
|
|
921
|
+
commands = {
|
|
922
|
+
"extract": cmd_extract, "query": cmd_query, "file-list": cmd_file_list,
|
|
923
|
+
"metadata": cmd_metadata, "dispatch-preview": cmd_dispatch_preview,
|
|
924
|
+
"module-dag": cmd_module_dag, "stale-files": cmd_stale_files,
|
|
925
|
+
"post-merge": cmd_post_merge, "audit": cmd_audit,
|
|
926
|
+
"record-feedback": cmd_record_feedback, "split-files": cmd_split_files,
|
|
927
|
+
"split-apply": cmd_split_apply, "merge-delta": cmd_merge_delta,
|
|
928
|
+
"anchor-fix": cmd_anchor_fix, "lock": cmd_lock, "diff-doc": cmd_diff_doc,
|
|
929
|
+
}
|
|
930
|
+
commands[args.command](args)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
if __name__ == "__main__":
|
|
934
|
+
main()
|