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.
Files changed (228) hide show
  1. cli/__init__.py +50 -0
  2. cli/__main__.py +5 -0
  3. cli/commands/__init__.py +1 -0
  4. cli/commands/anchor_fix.py +47 -0
  5. cli/commands/diff_doc.py +52 -0
  6. cli/commands/dispatch.py +77 -0
  7. cli/commands/extract.py +72 -0
  8. cli/commands/file_list.py +74 -0
  9. cli/commands/index.py +84 -0
  10. cli/commands/lock.py +89 -0
  11. cli/commands/merge.py +60 -0
  12. cli/commands/merge_delta.py +19 -0
  13. cli/commands/metadata.py +24 -0
  14. cli/commands/pipeline.py +45 -0
  15. cli/commands/post_merge.py +43 -0
  16. cli/commands/query.py +52 -0
  17. cli/commands/render.py +101 -0
  18. cli/commands/scan_repos.py +46 -0
  19. cli/commands/setup.py +94 -0
  20. cli/commands/split.py +196 -0
  21. cli/commands/stale_files.py +98 -0
  22. cli/commands/validate.py +191 -0
  23. core/__init__.py +32 -0
  24. core/config.py +261 -0
  25. core/docs/__init__.py +7 -0
  26. core/docs/section_updater.py +286 -0
  27. core/docs/shared.py +149 -0
  28. core/git.py +294 -0
  29. core/interfaces.py +249 -0
  30. core/monitor/__init__.py +5 -0
  31. core/monitor/progress.py +83 -0
  32. core/monitor/prompt_store.py +49 -0
  33. core/paths.py +141 -0
  34. core/preset.py +237 -0
  35. core/preset_accessors.py +202 -0
  36. core/preset_classify.py +132 -0
  37. core/preset_hooks.py +129 -0
  38. core/preset_profile.py +89 -0
  39. core/prompt/__init__.py +7 -0
  40. core/prompt/__main__.py +147 -0
  41. core/prompt/content.py +320 -0
  42. core/prompt/context_manager.py +164 -0
  43. core/prompt/renderer.py +236 -0
  44. core/prompt/response_parser.py +274 -0
  45. core/prompt/templates.py +357 -0
  46. core/prompt/validate_parity.py +162 -0
  47. core/prompt/variables.py +339 -0
  48. core/rag/__init__.py +22 -0
  49. core/rag/__main__.py +136 -0
  50. core/rag/bm25_index.py +268 -0
  51. core/rag/chunker.py +273 -0
  52. core/rag/embedder.py +151 -0
  53. core/rag/indexer.py +292 -0
  54. core/rag/loader.py +89 -0
  55. core/rag/retriever.py +82 -0
  56. core/skeleton/__init__.py +11 -0
  57. core/skeleton/__main__.py +934 -0
  58. core/skeleton/anchor_fix.py +250 -0
  59. core/skeleton/classify.py +331 -0
  60. core/skeleton/cmd_anchor_fix.py +43 -0
  61. core/skeleton/cmd_diff_doc.py +44 -0
  62. core/skeleton/cmd_lock.py +87 -0
  63. core/skeleton/cmd_merge_delta.py +41 -0
  64. core/skeleton/community.py +233 -0
  65. core/skeleton/dependency_graph.py +306 -0
  66. core/skeleton/diff_doc.py +248 -0
  67. core/skeleton/dispatch.py +273 -0
  68. core/skeleton/dispatch_render.py +319 -0
  69. core/skeleton/dispatch_source.py +111 -0
  70. core/skeleton/extract.py +218 -0
  71. core/skeleton/extract_methods.py +298 -0
  72. core/skeleton/file_list.py +239 -0
  73. core/skeleton/impact.py +278 -0
  74. core/skeleton/jar_download.py +177 -0
  75. core/skeleton/jar_resolver.py +186 -0
  76. core/skeleton/loader.py +162 -0
  77. core/skeleton/merge.py +278 -0
  78. core/skeleton/merge_delta.py +229 -0
  79. core/skeleton/metadata.py +96 -0
  80. core/skeleton/metadata_builders.py +264 -0
  81. core/skeleton/module_dag.py +330 -0
  82. core/skeleton/parsers/__init__.py +71 -0
  83. core/skeleton/parsers/jqassistant.py +300 -0
  84. core/skeleton/parsers/jqassistant_cypher.py +225 -0
  85. core/skeleton/parsers/regex.py +171 -0
  86. core/skeleton/parsers/treesitter.py +324 -0
  87. core/skeleton/parsers/treesitter_java.py +284 -0
  88. core/skeleton/parsers/treesitter_multi.py +289 -0
  89. core/skeleton/pom_parser.py +299 -0
  90. core/skeleton/post_merge.py +295 -0
  91. core/skeleton/post_merge_llm.py +82 -0
  92. core/skeleton/query.py +195 -0
  93. core/skeleton/shard_context.py +177 -0
  94. core/skeleton/split.py +180 -0
  95. core/skeleton/split_cache.py +107 -0
  96. core/skeleton/split_feedback.py +174 -0
  97. core/skeleton/split_plan.py +219 -0
  98. core/skeleton/split_plan_helpers.py +305 -0
  99. core/skeleton/split_plan_llm.py +274 -0
  100. core/utils.py +135 -0
  101. core/validators/__init__.py +65 -0
  102. core/validators/__main__.py +215 -0
  103. core/validators/consistency.py +203 -0
  104. core/validators/coverage.py +171 -0
  105. core/validators/duplicates.py +76 -0
  106. core/validators/engine.py +224 -0
  107. core/validators/links.py +76 -0
  108. core/validators/sampling.py +169 -0
  109. core/validators/structure.py +144 -0
  110. engine/__init__.py +7 -0
  111. engine/assembler.py +231 -0
  112. engine/confirm.py +65 -0
  113. engine/dedup.py +106 -0
  114. engine/main.py +211 -0
  115. engine/pipeline/__init__.py +163 -0
  116. engine/pipeline/recovery.py +250 -0
  117. engine/pipeline/steps/__init__.py +23 -0
  118. engine/pipeline/steps/audit.py +220 -0
  119. engine/pipeline/steps/audit_apply.py +195 -0
  120. engine/pipeline/steps/audit_helpers.py +155 -0
  121. engine/pipeline/steps/classify_llm.py +236 -0
  122. engine/pipeline/steps/classify_prompt.py +223 -0
  123. engine/pipeline/steps/finalize.py +160 -0
  124. engine/pipeline/steps/generate.py +169 -0
  125. engine/pipeline/steps/generate_batch.py +197 -0
  126. engine/pipeline/steps/generate_recovery.py +170 -0
  127. engine/pipeline/steps/llm_plan_split.py +253 -0
  128. engine/pipeline/steps/lock.py +64 -0
  129. engine/pipeline/steps/preflight.py +237 -0
  130. engine/pipeline/steps/preflight_adjust.py +147 -0
  131. engine/pipeline/steps/pregenerate.py +130 -0
  132. engine/pipeline/steps/quality.py +81 -0
  133. engine/pipeline/steps/skeleton.py +149 -0
  134. engine/pipeline/steps/source.py +163 -0
  135. engine/pipeline/steps/sync.py +117 -0
  136. engine/pipeline/steps/sync_finalize.py +237 -0
  137. engine/pipeline/steps/sync_update.py +341 -0
  138. engine/pipelines.py +91 -0
  139. engine/runner.py +335 -0
  140. engine/strategies/__init__.py +86 -0
  141. engine/strategies/api.py +128 -0
  142. engine/strategies/delegated.py +50 -0
  143. engine/strategies/dryrun.py +25 -0
  144. engine/two_phase.py +143 -0
  145. mcp_server/__init__.py +73 -0
  146. mcp_server/__main__.py +5 -0
  147. mcp_server/tools/__init__.py +1 -0
  148. mcp_server/tools/config.py +63 -0
  149. mcp_server/tools/discovery.py +276 -0
  150. mcp_server/tools/generation.py +184 -0
  151. mcp_server/tools/planning.py +144 -0
  152. mcp_server/tools/source.py +175 -0
  153. mcp_server/tools/validation.py +140 -0
  154. mcp_server/tools/workflow.py +166 -0
  155. mcp_server/workflow_loader.py +204 -0
  156. presets/generic/audit_dimensions.md +132 -0
  157. presets/generic/doc_types.yaml +152 -0
  158. presets/generic/preset.yaml +115 -0
  159. presets/java-spring/audit_dimensions.md +228 -0
  160. presets/java-spring/audit_dimensions.yaml +203 -0
  161. presets/java-spring/doc_types.yaml +269 -0
  162. presets/java-spring/hooks.py +122 -0
  163. presets/java-spring/preset.yaml +341 -0
  164. presets/java-spring/templates/README.md +34 -0
  165. presets/java-spring/templates/audit-system.md +15 -0
  166. presets/java-spring/templates/subagent-aop.md +105 -0
  167. presets/java-spring/templates/subagent-api.md +63 -0
  168. presets/java-spring/templates/subagent-architecture.md +111 -0
  169. presets/java-spring/templates/subagent-async-events.md +107 -0
  170. presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
  171. presets/java-spring/templates/subagent-audit-architecture.md +38 -0
  172. presets/java-spring/templates/subagent-audit-business.md +40 -0
  173. presets/java-spring/templates/subagent-audit-data-models.md +40 -0
  174. presets/java-spring/templates/subagent-business.md +129 -0
  175. presets/java-spring/templates/subagent-caching.md +75 -0
  176. presets/java-spring/templates/subagent-database-access.md +114 -0
  177. presets/java-spring/templates/subagent-enum.md +75 -0
  178. presets/java-spring/templates/subagent-error-handling.md +91 -0
  179. presets/java-spring/templates/subagent-external-integrations.md +80 -0
  180. presets/java-spring/templates/subagent-index.md +122 -0
  181. presets/java-spring/templates/subagent-messaging.md +97 -0
  182. presets/java-spring/templates/subagent-model.md +88 -0
  183. presets/java-spring/templates/subagent-observability.md +91 -0
  184. presets/java-spring/templates/subagent-scheduled.md +81 -0
  185. presets/java-spring/templates/subagent-security.md +102 -0
  186. presets/java-spring/templates/subagent-structure.md +101 -0
  187. presets/java-spring/templates/subagent-sync-section.md +34 -0
  188. presets/java-spring/templates/subagent-utils.md +73 -0
  189. presets/java-spring/templates/sync-system.md +8 -0
  190. presets/java-spring/workflow-extensions.md +112 -0
  191. skills/__init__.py +1 -0
  192. skills/_shared/README.md +30 -0
  193. skills/_shared/doc-coverage-shared.md +134 -0
  194. skills/_shared/doc-quality-standard.md +1058 -0
  195. skills/_shared/doc-subagent-rules.md +762 -0
  196. skills/_shared/windows-compat.md +89 -0
  197. skills/kb-audit/SKILL.md +52 -0
  198. skills/kb-audit/rules.md +88 -0
  199. skills/kb-audit/steps/step-01-prepare.md +75 -0
  200. skills/kb-audit/steps/step-02-audit.md +96 -0
  201. skills/kb-audit/steps/step-03-verify.md +65 -0
  202. skills/kb-audit/steps/step-04-report.md +64 -0
  203. skills/kb-init/SKILL.md +146 -0
  204. skills/kb-init/rules.md +187 -0
  205. skills/kb-init/steps/step-01-scope.md +62 -0
  206. skills/kb-init/steps/step-02-source.md +410 -0
  207. skills/kb-init/steps/step-03-generate.md +307 -0
  208. skills/kb-init/steps/step-04-quality.md +92 -0
  209. skills/kb-init/steps/step-05-finalize.md +132 -0
  210. skills/kb-init/templates/core/execution-modes.md +29 -0
  211. skills/kb-init/templates/core/output-only.md +4 -0
  212. skills/kb-init/templates/core/readwrite.md +33 -0
  213. skills/kb-search/SKILL.md +138 -0
  214. skills/kb-search/rules.md +64 -0
  215. skills/kb-sync/SKILL.md +43 -0
  216. skills/kb-sync/rules.md +70 -0
  217. skills/kb-sync/scripts/rebuild_module.py +91 -0
  218. skills/kb-sync/scripts/scan_repos.py +687 -0
  219. skills/kb-sync/steps/step-01-detect.md +72 -0
  220. skills/kb-sync/steps/step-02-update.md +71 -0
  221. skills/kb-sync/steps/step-03-verify.md +47 -0
  222. skills/kb-sync/steps/step-04-finalize.md +52 -0
  223. source_kb-0.2.2.dist-info/METADATA +194 -0
  224. source_kb-0.2.2.dist-info/RECORD +228 -0
  225. source_kb-0.2.2.dist-info/WHEEL +5 -0
  226. source_kb-0.2.2.dist-info/entry_points.txt +3 -0
  227. source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
  228. 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()