all-hands-cli 0.1.0

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 (305) hide show
  1. package/.allhands/README.md +75 -0
  2. package/.allhands/agents/compounder.yaml +15 -0
  3. package/.allhands/agents/coordinator.yaml +17 -0
  4. package/.allhands/agents/documentor.yaml +15 -0
  5. package/.allhands/agents/e2e-test-planner.yaml +17 -0
  6. package/.allhands/agents/emergent.yaml +22 -0
  7. package/.allhands/agents/executor.yaml +14 -0
  8. package/.allhands/agents/ideation.yaml +11 -0
  9. package/.allhands/agents/initiative-steering.yaml +19 -0
  10. package/.allhands/agents/judge.yaml +13 -0
  11. package/.allhands/agents/planner.yaml +19 -0
  12. package/.allhands/agents/pr-reviewer.yaml +15 -0
  13. package/.allhands/docs.json +5 -0
  14. package/.allhands/docs.local.json +26 -0
  15. package/.allhands/flows/COMPOUNDING.md +203 -0
  16. package/.allhands/flows/COORDINATION.md +89 -0
  17. package/.allhands/flows/CORE.md +87 -0
  18. package/.allhands/flows/DOCUMENTATION.md +218 -0
  19. package/.allhands/flows/E2E_TEST_PLAN_BUILDING.md +140 -0
  20. package/.allhands/flows/EMERGENT_PLANNING.md +57 -0
  21. package/.allhands/flows/IDEATION_SCOPING.md +154 -0
  22. package/.allhands/flows/INITIATIVE_STEERING.md +110 -0
  23. package/.allhands/flows/JUDGE_REVIEWING.md +79 -0
  24. package/.allhands/flows/PROMPT_TASK_EXECUTION.md +68 -0
  25. package/.allhands/flows/PR_REVIEWING.md +43 -0
  26. package/.allhands/flows/SPEC_PLANNING.md +216 -0
  27. package/.allhands/flows/harness/WRITING_HARNESS_FLOWS.md +27 -0
  28. package/.allhands/flows/harness/WRITING_HARNESS_KNOWLEDGE.md +27 -0
  29. package/.allhands/flows/harness/WRITING_HARNESS_ORCHESTRATION.md +27 -0
  30. package/.allhands/flows/harness/WRITING_HARNESS_SKILLS.md +27 -0
  31. package/.allhands/flows/harness/WRITING_HARNESS_TOOLS.md +27 -0
  32. package/.allhands/flows/harness/WRITING_HARNESS_VALIDATION_TOOLING.md +27 -0
  33. package/.allhands/flows/shared/CODEBASE_UNDERSTANDING.md +72 -0
  34. package/.allhands/flows/shared/CREATE_HARNESS_SPEC.md +48 -0
  35. package/.allhands/flows/shared/CREATE_SPEC.md +41 -0
  36. package/.allhands/flows/shared/CREATE_VALIDATION_TOOLING_SPEC.md +70 -0
  37. package/.allhands/flows/shared/DOCUMENTATION_DISCOVERY.md +123 -0
  38. package/.allhands/flows/shared/DOCUMENTATION_WRITER.md +101 -0
  39. package/.allhands/flows/shared/EMERGENT_REFINEMENT_ANALYSIS.md +76 -0
  40. package/.allhands/flows/shared/EXTERNAL_TECH_GUIDANCE.md +97 -0
  41. package/.allhands/flows/shared/IDEATION_CODEBASE_GROUNDING.md +49 -0
  42. package/.allhands/flows/shared/PLAN_DEEPENING.md +152 -0
  43. package/.allhands/flows/shared/PROMPT_TASKS_CURATION.md +113 -0
  44. package/.allhands/flows/shared/PROMPT_VALIDATION_REVIEW.MD +99 -0
  45. package/.allhands/flows/shared/QUICK_PREMORTEM.md +70 -0
  46. package/.allhands/flows/shared/RESEARCH_GUIDANCE.md +38 -0
  47. package/.allhands/flows/shared/REVIEW_OPTIONS_BREAKDOWN.md +68 -0
  48. package/.allhands/flows/shared/SKILL_EXTRACTION.md +84 -0
  49. package/.allhands/flows/shared/SPEC_FLOW_ANALYSIS.md +119 -0
  50. package/.allhands/flows/shared/TDD_WORKFLOW.md +109 -0
  51. package/.allhands/flows/shared/UTILIZE_VALIDATION_TOOLING.md +84 -0
  52. package/.allhands/flows/shared/WRITING_HARNESS_FLOWS.md +11 -0
  53. package/.allhands/flows/shared/WRITING_HARNESS_MCP_TOOLS.md +84 -0
  54. package/.allhands/flows/shared/jury/ARCHITECTURE_REVIEW.md +91 -0
  55. package/.allhands/flows/shared/jury/BEST_PRACTICES_REVIEW.md +80 -0
  56. package/.allhands/flows/shared/jury/CLAIM_VERIFICATION_REVIEW.md +101 -0
  57. package/.allhands/flows/shared/jury/EXPECTATIONS_FIT_REVIEW.md +78 -0
  58. package/.allhands/flows/shared/jury/MAINTAINABILITY_REVIEW.md +110 -0
  59. package/.allhands/flows/shared/jury/PROMPTS_EXPECTATIONS_FIT.md +74 -0
  60. package/.allhands/flows/shared/jury/PROMPTS_FLOW_ANALYSIS.md +92 -0
  61. package/.allhands/flows/shared/jury/PROMPTS_YAGNI.md +78 -0
  62. package/.allhands/flows/shared/jury/PROMPT_PREMORTEM.md +125 -0
  63. package/.allhands/flows/shared/jury/SECURITY_REVIEW.md +86 -0
  64. package/.allhands/flows/shared/jury/YAGNI_REVIEW.md +82 -0
  65. package/.allhands/flows/wip/DEBUG_INVESTIGATION.md +162 -0
  66. package/.allhands/flows/wip/MEMORY_RECALL.md +62 -0
  67. package/.allhands/harness/ah +131 -0
  68. package/.allhands/harness/package-lock.json +5292 -0
  69. package/.allhands/harness/package.json +52 -0
  70. package/.allhands/harness/src/__tests__/e2e/commands.test.ts +307 -0
  71. package/.allhands/harness/src/__tests__/e2e/event-loop.test.ts +539 -0
  72. package/.allhands/harness/src/__tests__/e2e/hooks.test.ts +427 -0
  73. package/.allhands/harness/src/__tests__/e2e/new-initiative-routing.test.ts +137 -0
  74. package/.allhands/harness/src/__tests__/e2e/run-e2e.ts +109 -0
  75. package/.allhands/harness/src/__tests__/e2e/specs-type.test.ts +210 -0
  76. package/.allhands/harness/src/__tests__/e2e/validation-hooks.test.ts +669 -0
  77. package/.allhands/harness/src/__tests__/e2e/validation-path-consistency.test.ts +354 -0
  78. package/.allhands/harness/src/__tests__/e2e/validation.test.ts +528 -0
  79. package/.allhands/harness/src/__tests__/harness/assertions.ts +318 -0
  80. package/.allhands/harness/src/__tests__/harness/cli-runner.ts +359 -0
  81. package/.allhands/harness/src/__tests__/harness/fixture.ts +384 -0
  82. package/.allhands/harness/src/__tests__/harness/hook-runner.ts +411 -0
  83. package/.allhands/harness/src/__tests__/harness/index.ts +122 -0
  84. package/.allhands/harness/src/cli.ts +36 -0
  85. package/.allhands/harness/src/commands/complexity.ts +177 -0
  86. package/.allhands/harness/src/commands/context7.ts +202 -0
  87. package/.allhands/harness/src/commands/docs.ts +557 -0
  88. package/.allhands/harness/src/commands/hooks.ts +24 -0
  89. package/.allhands/harness/src/commands/index.ts +51 -0
  90. package/.allhands/harness/src/commands/knowledge.ts +382 -0
  91. package/.allhands/harness/src/commands/memories.ts +302 -0
  92. package/.allhands/harness/src/commands/notify.ts +61 -0
  93. package/.allhands/harness/src/commands/oracle.ts +158 -0
  94. package/.allhands/harness/src/commands/perplexity.ts +220 -0
  95. package/.allhands/harness/src/commands/planning.ts +245 -0
  96. package/.allhands/harness/src/commands/schema.ts +73 -0
  97. package/.allhands/harness/src/commands/skills.ts +128 -0
  98. package/.allhands/harness/src/commands/solutions.ts +353 -0
  99. package/.allhands/harness/src/commands/spawn.ts +158 -0
  100. package/.allhands/harness/src/commands/specs.ts +532 -0
  101. package/.allhands/harness/src/commands/tavily.ts +226 -0
  102. package/.allhands/harness/src/commands/tools.ts +579 -0
  103. package/.allhands/harness/src/commands/trace.ts +327 -0
  104. package/.allhands/harness/src/commands/tui.ts +960 -0
  105. package/.allhands/harness/src/commands/validate.ts +143 -0
  106. package/.allhands/harness/src/commands/validation-tools.ts +108 -0
  107. package/.allhands/harness/src/hooks/context.ts +1442 -0
  108. package/.allhands/harness/src/hooks/enforcement.ts +170 -0
  109. package/.allhands/harness/src/hooks/index.ts +54 -0
  110. package/.allhands/harness/src/hooks/lifecycle.ts +229 -0
  111. package/.allhands/harness/src/hooks/notification.ts +104 -0
  112. package/.allhands/harness/src/hooks/observability.ts +551 -0
  113. package/.allhands/harness/src/hooks/session.ts +88 -0
  114. package/.allhands/harness/src/hooks/shared.ts +815 -0
  115. package/.allhands/harness/src/hooks/transcript-parser.ts +208 -0
  116. package/.allhands/harness/src/hooks/validation.ts +617 -0
  117. package/.allhands/harness/src/lib/__tests__/ctags.test.ts +244 -0
  118. package/.allhands/harness/src/lib/__tests__/docs-validation.test.ts +344 -0
  119. package/.allhands/harness/src/lib/__tests__/mcp-runtime.test.ts +190 -0
  120. package/.allhands/harness/src/lib/__tests__/schema.test.ts +861 -0
  121. package/.allhands/harness/src/lib/base-command.ts +198 -0
  122. package/.allhands/harness/src/lib/cli-daemon.ts +343 -0
  123. package/.allhands/harness/src/lib/compaction.ts +313 -0
  124. package/.allhands/harness/src/lib/ctags.ts +497 -0
  125. package/.allhands/harness/src/lib/docs-validation.ts +907 -0
  126. package/.allhands/harness/src/lib/event-loop.ts +662 -0
  127. package/.allhands/harness/src/lib/flows.ts +155 -0
  128. package/.allhands/harness/src/lib/git.ts +276 -0
  129. package/.allhands/harness/src/lib/knowledge-worker.ts +72 -0
  130. package/.allhands/harness/src/lib/knowledge.ts +810 -0
  131. package/.allhands/harness/src/lib/llm.ts +255 -0
  132. package/.allhands/harness/src/lib/mcp-client.ts +432 -0
  133. package/.allhands/harness/src/lib/mcp-daemon.ts +486 -0
  134. package/.allhands/harness/src/lib/mcp-runtime.ts +418 -0
  135. package/.allhands/harness/src/lib/notification.ts +115 -0
  136. package/.allhands/harness/src/lib/opencode/index.ts +70 -0
  137. package/.allhands/harness/src/lib/opencode/profiles.ts +300 -0
  138. package/.allhands/harness/src/lib/opencode/prompts/codesearch.md +98 -0
  139. package/.allhands/harness/src/lib/opencode/prompts/knowledge-aggregator.md +67 -0
  140. package/.allhands/harness/src/lib/opencode/runner.ts +281 -0
  141. package/.allhands/harness/src/lib/oracle.ts +926 -0
  142. package/.allhands/harness/src/lib/planning-utils.ts +150 -0
  143. package/.allhands/harness/src/lib/planning.ts +605 -0
  144. package/.allhands/harness/src/lib/pr-review.ts +225 -0
  145. package/.allhands/harness/src/lib/prompts.ts +522 -0
  146. package/.allhands/harness/src/lib/schema.ts +418 -0
  147. package/.allhands/harness/src/lib/schemas/agent-profile.ts +141 -0
  148. package/.allhands/harness/src/lib/schemas/template-vars.ts +138 -0
  149. package/.allhands/harness/src/lib/session.ts +164 -0
  150. package/.allhands/harness/src/lib/specs.ts +348 -0
  151. package/.allhands/harness/src/lib/tldr.ts +829 -0
  152. package/.allhands/harness/src/lib/tmux.ts +1051 -0
  153. package/.allhands/harness/src/lib/trace-store.ts +714 -0
  154. package/.allhands/harness/src/mcp/__tests__/index.test.ts +46 -0
  155. package/.allhands/harness/src/mcp/_template.ts +47 -0
  156. package/.allhands/harness/src/mcp/filesystem.ts +33 -0
  157. package/.allhands/harness/src/mcp/index.ts +69 -0
  158. package/.allhands/harness/src/mcp/playwright.ts +34 -0
  159. package/.allhands/harness/src/mcp/xcodebuild.ts +29 -0
  160. package/.allhands/harness/src/schemas/docs.schema.json +44 -0
  161. package/.allhands/harness/src/schemas/settings.schema.json +214 -0
  162. package/.allhands/harness/src/tui/actions.ts +227 -0
  163. package/.allhands/harness/src/tui/file-viewer-modal.ts +270 -0
  164. package/.allhands/harness/src/tui/index.ts +1574 -0
  165. package/.allhands/harness/src/tui/modal.ts +232 -0
  166. package/.allhands/harness/src/tui/prompts-pane.ts +186 -0
  167. package/.allhands/harness/src/tui/status-pane.ts +434 -0
  168. package/.allhands/harness/tsconfig.json +22 -0
  169. package/.allhands/harness/vitest.config.ts +13 -0
  170. package/.allhands/pillars.md +33 -0
  171. package/.allhands/principles.md +88 -0
  172. package/.allhands/schemas/alignment.yaml +51 -0
  173. package/.allhands/schemas/documentation.yaml +10 -0
  174. package/.allhands/schemas/prompt.yaml +92 -0
  175. package/.allhands/schemas/skill.yaml +34 -0
  176. package/.allhands/schemas/solution.yaml +131 -0
  177. package/.allhands/schemas/spec.yaml +67 -0
  178. package/.allhands/schemas/validation-suite.yaml +49 -0
  179. package/.allhands/schemas/workflow.yaml +51 -0
  180. package/.allhands/settings.json +57 -0
  181. package/.allhands/skills/claude-code-patterns/SKILL.md +60 -0
  182. package/.allhands/skills/claude-code-patterns/docs/context-hygiene.md +19 -0
  183. package/.allhands/skills/harness-maintenance/SKILL.md +449 -0
  184. package/.allhands/skills/harness-maintenance/references/core-architecture.md +187 -0
  185. package/.allhands/skills/harness-maintenance/references/harness-skills.md +87 -0
  186. package/.allhands/skills/harness-maintenance/references/knowledge-compounding.md +78 -0
  187. package/.allhands/skills/harness-maintenance/references/tools-commands-mcp-hooks.md +115 -0
  188. package/.allhands/skills/harness-maintenance/references/validation-tooling.md +77 -0
  189. package/.allhands/skills/harness-maintenance/references/writing-flows.md +84 -0
  190. package/.allhands/validation/browser-automation.md +109 -0
  191. package/.allhands/validation/xcode-automation.md +195 -0
  192. package/.allhands/workflows/documentation.md +86 -0
  193. package/.allhands/workflows/investigation.md +81 -0
  194. package/.allhands/workflows/milestone.md +91 -0
  195. package/.allhands/workflows/optimization.md +85 -0
  196. package/.allhands/workflows/refactor.md +99 -0
  197. package/.allhands/workflows/triage.md +81 -0
  198. package/.claude/README.md +1 -0
  199. package/.claude/agents/explorer.md +10 -0
  200. package/.claude/agents/researcher.md +11 -0
  201. package/.claude/agents/task-runner.md +8 -0
  202. package/.claude/settings.json +231 -0
  203. package/.env.ai.example +7 -0
  204. package/.github/workflows/npm-publish.yml +69 -0
  205. package/.internal.json +45 -0
  206. package/.tldr/config.json +11 -0
  207. package/.tldrignore +90 -0
  208. package/CLAUDE.md +6 -0
  209. package/README.md +98 -0
  210. package/bin/sync-cli.js +7552 -0
  211. package/concerns.md +7 -0
  212. package/docs/README.md +41 -0
  213. package/docs/agents/README.md +24 -0
  214. package/docs/agents/agent-configuration-system.md +86 -0
  215. package/docs/agents/execution-agents.md +50 -0
  216. package/docs/agents/knowledge-agents.md +61 -0
  217. package/docs/agents/orchestration-agent.md +57 -0
  218. package/docs/agents/planning-agents.md +84 -0
  219. package/docs/agents/quality-review-agents.md +67 -0
  220. package/docs/agents/workflow-agent-orchestration.md +69 -0
  221. package/docs/flows/README.md +44 -0
  222. package/docs/flows/compounding.md +126 -0
  223. package/docs/flows/coordination.md +72 -0
  224. package/docs/flows/core-harness-integration.md +63 -0
  225. package/docs/flows/documentation-orchestration.md +98 -0
  226. package/docs/flows/e2e-test-plan-building.md +83 -0
  227. package/docs/flows/emergent-refinement.md +104 -0
  228. package/docs/flows/flow-authoring-and-mcp-tools.md +89 -0
  229. package/docs/flows/judge-reviewing.md +112 -0
  230. package/docs/flows/plan-deepening-and-research.md +107 -0
  231. package/docs/flows/plan-review-jury.md +114 -0
  232. package/docs/flows/pr-reviewing.md +54 -0
  233. package/docs/flows/prompt-task-execution.md +119 -0
  234. package/docs/flows/spec-planning.md +162 -0
  235. package/docs/flows/type-specific-scoping-flows.md +49 -0
  236. package/docs/flows/validation-and-skills-integration.md +145 -0
  237. package/docs/flows/wip/wip-flows.md +102 -0
  238. package/docs/harness/README.md +23 -0
  239. package/docs/harness/agent-profiles.md +84 -0
  240. package/docs/harness/cli/README.md +24 -0
  241. package/docs/harness/cli/cli-entry-and-command-discovery.md +91 -0
  242. package/docs/harness/cli/docs-command.md +87 -0
  243. package/docs/harness/cli/knowledge-command.md +91 -0
  244. package/docs/harness/cli/minor-cli-commands.md +65 -0
  245. package/docs/harness/cli/oracle-command.md +113 -0
  246. package/docs/harness/cli/planning-command.md +95 -0
  247. package/docs/harness/cli/schema-and-validation-commands.md +154 -0
  248. package/docs/harness/cli/search-commands.md +97 -0
  249. package/docs/harness/cli/spawn-command.md +136 -0
  250. package/docs/harness/cli/specs-command.md +102 -0
  251. package/docs/harness/cli/tools-command.md +122 -0
  252. package/docs/harness/cli/trace-command.md +122 -0
  253. package/docs/harness/cli-daemon.md +92 -0
  254. package/docs/harness/event-loop.md +184 -0
  255. package/docs/harness/hooks/README.md +15 -0
  256. package/docs/harness/hooks/context-hooks.md +96 -0
  257. package/docs/harness/hooks/lifecycle-and-observability-hooks.md +135 -0
  258. package/docs/harness/hooks/validation-hooks.md +97 -0
  259. package/docs/harness/test-harness.md +149 -0
  260. package/docs/harness/tui.md +176 -0
  261. package/docs/memories.md +20 -0
  262. package/docs/solutions/agentic-issues/premature-agent-deletion-tui-action-dependency-20260130.md +49 -0
  263. package/docs/solutions/agentic-issues/ref-anchor-scope-mismatch-skill-references-20260131.md +55 -0
  264. package/docs/solutions/agentic-issues/tautological-tests-routing-20260131.md +52 -0
  265. package/docs/solutions/integration_issue/blocktool-output-format-mismatch-hook-runner-20260130.md +52 -0
  266. package/docs/solutions/integration_issue/dual-validation-path-divergence-schema-20260130.md +66 -0
  267. package/docs/solutions/security-issues/unsanitized-domain-path-join-20260131.md +52 -0
  268. package/docs/solutions/test-failures/event-loop-mock-ordering-checkAgentWindows-20260130.md +63 -0
  269. package/docs/sync-cli/README.md +19 -0
  270. package/docs/sync-cli/cli-entrypoint-and-commands.md +39 -0
  271. package/docs/sync-cli/commands/README.md +11 -0
  272. package/docs/sync-cli/commands/pull-manifest-command.md +36 -0
  273. package/docs/sync-cli/commands/push-command.md +84 -0
  274. package/docs/sync-cli/commands/sync-command.md +71 -0
  275. package/docs/sync-cli/systems/README.md +14 -0
  276. package/docs/sync-cli/systems/git-and-github-integration.md +49 -0
  277. package/docs/sync-cli/systems/interactive-ui.md +43 -0
  278. package/docs/sync-cli/systems/manifest-and-distribution.md +51 -0
  279. package/docs/sync-cli/systems/path-resolution.md +42 -0
  280. package/package.json +46 -0
  281. package/scripts/install-shim.sh +40 -0
  282. package/scripts/pre-pack.sh +25 -0
  283. package/specs/harness-maintenance-skill.spec.md +138 -0
  284. package/specs/roadmap/git-spec-lifecycle-management.spec.md +113 -0
  285. package/specs/sync-init-flag.spec.md +117 -0
  286. package/specs/unified-workflow-orchestration.spec.md +250 -0
  287. package/specs/validation-tooling-practice.spec.md +98 -0
  288. package/specs/workflow-domain-configuration.spec.md +265 -0
  289. package/src/commands/pull-manifest.ts +31 -0
  290. package/src/commands/push.ts +344 -0
  291. package/src/commands/sync.ts +289 -0
  292. package/src/lib/constants.ts +10 -0
  293. package/src/lib/dotfiles.ts +36 -0
  294. package/src/lib/fs-utils.ts +18 -0
  295. package/src/lib/gh.ts +40 -0
  296. package/src/lib/git.ts +63 -0
  297. package/src/lib/gitignore.ts +167 -0
  298. package/src/lib/manifest.ts +121 -0
  299. package/src/lib/marker-sync.ts +39 -0
  300. package/src/lib/paths.ts +38 -0
  301. package/src/lib/target-lines.ts +66 -0
  302. package/src/lib/ui.ts +78 -0
  303. package/src/sync-cli.ts +120 -0
  304. package/target-lines.json +23 -0
  305. package/tsconfig.json +20 -0
@@ -0,0 +1,907 @@
1
+ /**
2
+ * Documentation validation utilities.
3
+ *
4
+ * Validates references in documentation files against the actual codebase
5
+ * using ctags for symbol lookup and git for staleness detection.
6
+ *
7
+ * Reference formats:
8
+ * [ref:file:symbol:hash] - Symbol reference (validated via ctags)
9
+ * [ref:file::hash] - File-only reference (no symbol validation)
10
+ *
11
+ * Where hash = git blob hash (content-addressable, stable across merges/rebases)
12
+ */
13
+
14
+ import { spawnSync } from "child_process";
15
+ import { createHash } from "crypto";
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
17
+ import { extname, join, relative } from "path";
18
+ import matter from "gray-matter";
19
+ import { CtagsIndex, generateCtagsIndex, generateCtagsIndexAsync, lookupSymbol } from "./ctags.js";
20
+
21
+ /**
22
+ * File extensions that ctags can process (programming languages).
23
+ * Files with other extensions are treated as non-code where symbols are just labels.
24
+ */
25
+ const CODE_EXTENSIONS = new Set([
26
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", // TypeScript/JavaScript
27
+ ".py", ".pyw", // Python
28
+ ".go", // Go
29
+ ".rs", // Rust
30
+ ".java", // Java
31
+ ".rb", // Ruby
32
+ ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", // C/C++
33
+ ".cs", // C#
34
+ ".php", // PHP
35
+ ".kt", ".kts", // Kotlin
36
+ ".swift", // Swift
37
+ ".scala", // Scala
38
+ ".lua", // Lua
39
+ ".sh", ".bash", ".zsh", // Shell scripts
40
+ ".vim", // Vim script
41
+ ".el", // Emacs Lisp
42
+ ]);
43
+
44
+ /**
45
+ * Check if a file is a code file that ctags can process.
46
+ */
47
+ export function isCodeFile(filePath: string): boolean {
48
+ const ext = extname(filePath).toLowerCase();
49
+ return CODE_EXTENSIONS.has(ext);
50
+ }
51
+
52
+ /**
53
+ * Validation cache for faster repeated validation runs.
54
+ */
55
+ interface ValidationCache {
56
+ lastRun: string;
57
+ /** Map of doc path -> content hash */
58
+ docChecksums: Record<string, string>;
59
+ /** Map of doc path -> last validation issues (empty if clean) */
60
+ docIssues: Record<string, DocFileIssues>;
61
+ /** Map of referenced file path -> last known hash */
62
+ refFileHashes: Record<string, string>;
63
+ }
64
+
65
+ const CACHE_DIR = ".allhands/harness/.cache";
66
+ const CACHE_FILE = "docs-validation.json";
67
+
68
+ /**
69
+ * Get content hash for a file.
70
+ */
71
+ function getContentHash(content: string): string {
72
+ return createHash("md5").update(content).digest("hex").substring(0, 12);
73
+ }
74
+
75
+ /**
76
+ * Load validation cache from disk.
77
+ */
78
+ function loadValidationCache(projectRoot: string): ValidationCache | null {
79
+ const cachePath = join(projectRoot, CACHE_DIR, CACHE_FILE);
80
+ if (!existsSync(cachePath)) {
81
+ return null;
82
+ }
83
+ try {
84
+ return JSON.parse(readFileSync(cachePath, "utf-8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Save validation cache to disk.
92
+ */
93
+ function saveValidationCache(projectRoot: string, cache: ValidationCache): void {
94
+ const cacheDir = join(projectRoot, CACHE_DIR);
95
+ if (!existsSync(cacheDir)) {
96
+ mkdirSync(cacheDir, { recursive: true });
97
+ }
98
+ const cachePath = join(cacheDir, CACHE_FILE);
99
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2));
100
+ }
101
+
102
+ /**
103
+ * Reference pattern matching both symbol and file-only refs.
104
+ * Captures: [1]=file, [2]=symbol (empty for file-only), [3]=hash (min 7 chars)
105
+ */
106
+ export const REF_PATTERN = /\[ref:([^:\]]+):([^:\]]*):([a-f0-9]{7,})\]/g;
107
+
108
+ /**
109
+ * Placeholder hash patterns (fake/test hashes that should be replaced).
110
+ */
111
+ export const PLACEHOLDER_PATTERN =
112
+ /\[ref:[^\]]+:(abc123[0-9]?|123456[0-9]?|000000[0-9]?|hash[a-f0-9]{0,4}|test[a-f0-9]{0,4})\]/gi;
113
+
114
+ /**
115
+ * Unfinalized ref pattern - refs without hashes that need to be finalized.
116
+ * Matches [ref:file:symbol] or [ref:file] (no hash component).
117
+ */
118
+ export const UNFINALIZED_REF_PATTERN = /\[ref:([^:\]]+)(?::([^\]]*))?\](?!:)/g;
119
+
120
+ /**
121
+ * Parsed reference from documentation.
122
+ */
123
+ export interface ParsedRef {
124
+ /** Full match string [ref:...] */
125
+ reference: string;
126
+ /** Source file path (relative) */
127
+ file: string;
128
+ /** Symbol name (null for file-only refs) */
129
+ symbol: string | null;
130
+ /** Stored hash in the reference */
131
+ hash: string;
132
+ /** Whether this is a file-only ref */
133
+ isFileOnly: boolean;
134
+ /** Doc file containing this reference */
135
+ docFile: string;
136
+ }
137
+
138
+ /**
139
+ * Validation state for a reference.
140
+ */
141
+ export type RefState = "valid" | "stale" | "invalid";
142
+
143
+ /**
144
+ * Validated reference with state and details.
145
+ */
146
+ export interface ValidatedRef extends ParsedRef {
147
+ state: RefState;
148
+ /** Reason for invalid/stale state */
149
+ reason?: string;
150
+ /** Current hash (for stale refs) */
151
+ currentHash?: string;
152
+ }
153
+
154
+ /**
155
+ * Issues found in a documentation file.
156
+ */
157
+ export interface DocFileIssues {
158
+ stale: Array<{
159
+ reference: string;
160
+ file_path: string;
161
+ symbol_name: string | null;
162
+ stored_hash: string;
163
+ current_hash: string;
164
+ ref_type: "symbol" | "file-only";
165
+ }>;
166
+ invalid: Array<{
167
+ reference: string;
168
+ reason: string;
169
+ }>;
170
+ frontmatter_error: string | null;
171
+ placeholder_errors: string[];
172
+ unfinalized_refs: string[];
173
+ inline_code_block_count: number;
174
+ has_capability_list_warning: boolean;
175
+ }
176
+
177
+ /**
178
+ * Full validation result.
179
+ */
180
+ export interface ValidationResult {
181
+ message: string;
182
+ total_files: number;
183
+ total_refs: number;
184
+ symbol_refs: number;
185
+ file_only_refs: number;
186
+ valid_count: number;
187
+ frontmatter_error_count: number;
188
+ stale_count: number;
189
+ invalid_count: number;
190
+ placeholder_error_count: number;
191
+ unfinalized_ref_count: number;
192
+ inline_code_error_count: number;
193
+ capability_list_warning_count: number;
194
+ by_doc_file: Record<string, DocFileIssues>;
195
+ frontmatter_errors: Array<{ doc_file: string; reason: string }>;
196
+ stale: Array<{
197
+ doc_file: string;
198
+ reference: string;
199
+ stored_hash: string;
200
+ current_hash: string;
201
+ ref_type: "symbol" | "file-only";
202
+ }>;
203
+ invalid: Array<{ doc_file: string; reference: string; reason: string }>;
204
+ placeholder_errors: Array<{
205
+ doc_file: string;
206
+ count: number;
207
+ examples: string[];
208
+ reason: string;
209
+ }>;
210
+ unfinalized_refs: Array<{
211
+ doc_file: string;
212
+ count: number;
213
+ examples: string[];
214
+ reason: string;
215
+ }>;
216
+ inline_code_errors: Array<{
217
+ doc_file: string;
218
+ block_count: number;
219
+ reason: string;
220
+ }>;
221
+ capability_list_warnings: Array<{ doc_file: string; reason: string }>;
222
+ }
223
+
224
+ /**
225
+ * Get the git blob hash for a file (content-addressable, stable across merges/rebases).
226
+ * Uses `git rev-parse HEAD:<relative-path>` which returns the blob SHA for the file's content.
227
+ */
228
+ export function getBlobHashForFile(
229
+ filePath: string,
230
+ cwd: string
231
+ ): { hash: string; success: boolean } {
232
+ // Normalize to repo-relative path
233
+ const relPath = filePath.startsWith(cwd)
234
+ ? relative(cwd, filePath)
235
+ : filePath;
236
+
237
+ const result = spawnSync("git", ["rev-parse", `HEAD:${relPath}`], {
238
+ encoding: "utf-8",
239
+ cwd,
240
+ });
241
+
242
+ if (result.status !== 0 || !result.stdout.trim()) {
243
+ return { hash: "0000000", success: false };
244
+ }
245
+
246
+ return { hash: result.stdout.trim().substring(0, 7), success: true };
247
+ }
248
+
249
+ /**
250
+ * Batch get git blob hashes for multiple files using a single `git ls-tree -r HEAD` call.
251
+ * Much faster than calling getBlobHashForFile N times, and produces content-addressable
252
+ * hashes that are stable across merges, rebases, and squash merges.
253
+ */
254
+ export function batchGetBlobHashes(
255
+ files: string[],
256
+ cwd: string
257
+ ): Map<string, { hash: string; success: boolean }> {
258
+ const results = new Map<string, { hash: string; success: boolean }>();
259
+
260
+ if (files.length === 0) {
261
+ return results;
262
+ }
263
+
264
+ // Resolve git repo root (ls-tree paths are always repo-root-relative)
265
+ const repoRootResult = spawnSync("git", ["rev-parse", "--show-toplevel"], {
266
+ encoding: "utf-8",
267
+ cwd,
268
+ });
269
+ const repoRoot = repoRootResult.status === 0
270
+ ? repoRootResult.stdout.trim()
271
+ : cwd;
272
+
273
+ // Single git ls-tree call to get all blob hashes
274
+ const lsResult = spawnSync(
275
+ "git",
276
+ ["ls-tree", "-r", "HEAD"],
277
+ { encoding: "utf-8", cwd, maxBuffer: 10 * 1024 * 1024 }
278
+ );
279
+
280
+ // Build a map of repo-root-relative path -> 7-char blob hash
281
+ const blobMap = new Map<string, string>();
282
+ if (lsResult.status === 0 && lsResult.stdout) {
283
+ for (const line of lsResult.stdout.trim().split("\n")) {
284
+ if (!line) continue;
285
+ // Format: <mode> <type> <sha>\t<path>
286
+ const tabIdx = line.indexOf("\t");
287
+ if (tabIdx === -1) continue;
288
+ const path = line.substring(tabIdx + 1);
289
+ const parts = line.substring(0, tabIdx).split(" ");
290
+ if (parts.length >= 3 && parts[1] === "blob") {
291
+ blobMap.set(path, parts[2].substring(0, 7));
292
+ }
293
+ }
294
+ }
295
+
296
+ // Compute prefix to convert cwd-relative paths to repo-root-relative paths
297
+ const cwdPrefix = cwd !== repoRoot ? relative(repoRoot, cwd) : "";
298
+
299
+ // Look up each requested file in the blob map
300
+ for (const file of files) {
301
+ // Normalize file path to repo-root-relative
302
+ let repoRelPath: string;
303
+ if (file.startsWith("/")) {
304
+ repoRelPath = relative(repoRoot, file);
305
+ } else if (cwdPrefix) {
306
+ repoRelPath = join(cwdPrefix, file);
307
+ } else {
308
+ repoRelPath = file;
309
+ }
310
+
311
+ const hash = blobMap.get(repoRelPath);
312
+ if (hash) {
313
+ results.set(file, { hash, success: true });
314
+ } else {
315
+ // Fall back to individual lookup for misses (e.g., submodules, unusual paths)
316
+ results.set(file, getBlobHashForFile(file, cwd));
317
+ }
318
+ }
319
+
320
+ return results;
321
+ }
322
+
323
+ /**
324
+ * Extract all references from markdown content.
325
+ */
326
+ export function extractRefs(content: string, docFile: string): ParsedRef[] {
327
+ const refs: ParsedRef[] = [];
328
+
329
+ // Reset regex state
330
+ REF_PATTERN.lastIndex = 0;
331
+
332
+ let match;
333
+ while ((match = REF_PATTERN.exec(content)) !== null) {
334
+ const isFileOnly = match[2] === "";
335
+ refs.push({
336
+ reference: match[0],
337
+ file: match[1],
338
+ symbol: isFileOnly ? null : match[2],
339
+ hash: match[3],
340
+ isFileOnly,
341
+ docFile,
342
+ });
343
+ }
344
+
345
+ return refs;
346
+ }
347
+
348
+ /**
349
+ * Validate front matter in a markdown file.
350
+ */
351
+ export function validateFrontMatter(
352
+ content: string
353
+ ): { valid: boolean; error?: string } {
354
+ // Must start with ---
355
+ if (!content.startsWith("---")) {
356
+ return { valid: false, error: "Missing front matter (file must start with ---)" };
357
+ }
358
+
359
+ try {
360
+ const parsed = matter(content);
361
+
362
+ // Check for required description field
363
+ if (parsed.data.description === undefined || parsed.data.description === null) {
364
+ return {
365
+ valid: false,
366
+ error: "Missing 'description' field in front matter",
367
+ };
368
+ }
369
+
370
+ if (typeof parsed.data.description !== "string") {
371
+ return {
372
+ valid: false,
373
+ error: "Invalid 'description' field in front matter (must be a string)",
374
+ };
375
+ }
376
+
377
+ if (parsed.data.description.trim() === "") {
378
+ return { valid: false, error: "Empty 'description' field in front matter" };
379
+ }
380
+
381
+ // Validate relevant_files if present
382
+ if (
383
+ parsed.data.relevant_files !== undefined &&
384
+ !Array.isArray(parsed.data.relevant_files)
385
+ ) {
386
+ return { valid: false, error: "'relevant_files' must be an array" };
387
+ }
388
+
389
+ return { valid: true };
390
+ } catch {
391
+ return { valid: false, error: "Invalid front matter syntax" };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Detect placeholder hashes in content (fake hashes like abc1234).
397
+ */
398
+ export function detectPlaceholders(content: string): string[] {
399
+ // Reset regex state
400
+ PLACEHOLDER_PATTERN.lastIndex = 0;
401
+
402
+ const matches = content.match(PLACEHOLDER_PATTERN);
403
+ return matches || [];
404
+ }
405
+
406
+ /**
407
+ * Detect unfinalized refs in content (refs without hashes).
408
+ * These are refs like [ref:file:symbol] or [ref:file] that haven't been finalized.
409
+ */
410
+ export function detectUnfinalizedRefs(content: string): string[] {
411
+ const results: string[] = [];
412
+ UNFINALIZED_REF_PATTERN.lastIndex = 0;
413
+
414
+ let match;
415
+ while ((match = UNFINALIZED_REF_PATTERN.exec(content)) !== null) {
416
+ const fullMatch = match[0];
417
+ // Check if this looks like a finalized ref (has 3+ colons with hash)
418
+ // Finalized refs have format [ref:file:symbol:hash] or [ref:file::hash]
419
+ const colonCount = (fullMatch.match(/:/g) || []).length;
420
+ const hasHash = /:[a-f0-9]{7,}\]$/.test(fullMatch);
421
+ if (!hasHash && colonCount < 3) {
422
+ results.push(fullMatch);
423
+ }
424
+ }
425
+ return results;
426
+ }
427
+
428
+ /**
429
+ * Count fenced code blocks in content.
430
+ */
431
+ export function countCodeBlocks(content: string): number {
432
+ // Count complete fenced code blocks by matching open and close pairs
433
+ // Pattern: ```[lang]\n...content...\n```
434
+ const pattern = /^```[a-z0-9_+-]*\r?\n[\s\S]*?^```$/gm;
435
+ const matches = content.match(pattern);
436
+ return matches ? matches.length : 0;
437
+ }
438
+
439
+ /**
440
+ * Detect capability list tables.
441
+ */
442
+ export function hasCapabilityList(content: string): boolean {
443
+ // Detect markdown tables that look like capability/command listings
444
+ // Match lines that have Command|Option|Flag in one column and Purpose|Description in another
445
+ const pattern =
446
+ /\|\s*(Command|Option|Flag)\s*\|.*?(Purpose|Description)/i;
447
+ return pattern.test(content);
448
+ }
449
+
450
+ /**
451
+ * Recursively find all markdown files in a directory.
452
+ */
453
+ export function findMarkdownFiles(dir: string, excludeReadme = false, excludePaths: string[] = []): string[] {
454
+ const files: string[] = [];
455
+
456
+ if (!existsSync(dir)) {
457
+ return files;
458
+ }
459
+
460
+ const entries = readdirSync(dir);
461
+ for (const entry of entries) {
462
+ const fullPath = join(dir, entry);
463
+
464
+ // Skip excluded paths (exact match for files, prefix match for directories)
465
+ if (excludePaths.some(ep => fullPath === ep || fullPath.startsWith(ep + "/"))) {
466
+ continue;
467
+ }
468
+
469
+ const stat = statSync(fullPath);
470
+
471
+ if (stat.isDirectory()) {
472
+ files.push(...findMarkdownFiles(fullPath, excludeReadme, excludePaths));
473
+ } else if (entry.endsWith(".md")) {
474
+ if (excludeReadme && entry === "README.md") {
475
+ continue;
476
+ }
477
+ files.push(fullPath);
478
+ }
479
+ }
480
+
481
+ return files;
482
+ }
483
+
484
+ /**
485
+ * Validate a single reference.
486
+ * @param ref - The parsed reference to validate
487
+ * @param ctagsIndex - The ctags index for symbol lookup
488
+ * @param projectRoot - The project root directory
489
+ * @param hashCache - Optional pre-fetched hash map for performance
490
+ */
491
+ export function validateRef(
492
+ ref: ParsedRef,
493
+ ctagsIndex: CtagsIndex,
494
+ projectRoot: string,
495
+ hashCache?: Map<string, { hash: string; success: boolean }>
496
+ ): ValidatedRef {
497
+ const absolutePath = join(projectRoot, ref.file);
498
+
499
+ // Check file exists
500
+ if (!existsSync(absolutePath)) {
501
+ return {
502
+ ...ref,
503
+ state: "invalid",
504
+ reason: "File not found",
505
+ };
506
+ }
507
+
508
+ // Get current file hash (from cache or fetch)
509
+ let hashResult: { hash: string; success: boolean };
510
+ if (hashCache && hashCache.has(absolutePath)) {
511
+ hashResult = hashCache.get(absolutePath)!;
512
+ } else if (hashCache && hashCache.has(ref.file)) {
513
+ hashResult = hashCache.get(ref.file)!;
514
+ } else {
515
+ hashResult = getBlobHashForFile(absolutePath, projectRoot);
516
+ }
517
+
518
+ const { hash: currentHash, success } = hashResult;
519
+
520
+ if (!success) {
521
+ return {
522
+ ...ref,
523
+ state: "invalid",
524
+ reason: "Git hash lookup failed (uncommitted file?)",
525
+ };
526
+ }
527
+
528
+ // File-only reference: just check hash
529
+ if (ref.isFileOnly) {
530
+ if (currentHash !== ref.hash) {
531
+ return {
532
+ ...ref,
533
+ state: "stale",
534
+ reason: "File has been modified",
535
+ currentHash,
536
+ };
537
+ }
538
+ return { ...ref, state: "valid" };
539
+ }
540
+
541
+ // Non-code files (markdown, yaml, json, etc.): treat symbol as label, just check hash
542
+ if (!isCodeFile(ref.file)) {
543
+ if (currentHash !== ref.hash) {
544
+ return {
545
+ ...ref,
546
+ state: "stale",
547
+ reason: "File has been modified",
548
+ currentHash,
549
+ };
550
+ }
551
+ return { ...ref, state: "valid" };
552
+ }
553
+
554
+ // Code file symbol reference: check symbol exists via ctags
555
+ const entries = lookupSymbol(ctagsIndex, ref.file, ref.symbol!);
556
+
557
+ if (entries.length === 0) {
558
+ return {
559
+ ...ref,
560
+ state: "invalid",
561
+ reason: `Symbol '${ref.symbol}' not found in ${ref.file}`,
562
+ };
563
+ }
564
+
565
+ // Check hash staleness (using file-level hash for ctags approach)
566
+ if (currentHash !== ref.hash) {
567
+ return {
568
+ ...ref,
569
+ state: "stale",
570
+ reason: "File has been modified since reference was created",
571
+ currentHash,
572
+ };
573
+ }
574
+
575
+ return { ...ref, state: "valid" };
576
+ }
577
+
578
+ /**
579
+ * Validate all documentation in a directory.
580
+ */
581
+ export function validateDocs(
582
+ docsPath: string,
583
+ projectRoot: string,
584
+ options?: { ctagsIndex?: CtagsIndex; useCache?: boolean; excludePaths?: string[] }
585
+ ): ValidationResult {
586
+ // Initialize result
587
+ const result: ValidationResult = {
588
+ message: "",
589
+ total_files: 0,
590
+ total_refs: 0,
591
+ symbol_refs: 0,
592
+ file_only_refs: 0,
593
+ valid_count: 0,
594
+ frontmatter_error_count: 0,
595
+ stale_count: 0,
596
+ invalid_count: 0,
597
+ placeholder_error_count: 0,
598
+ unfinalized_ref_count: 0,
599
+ inline_code_error_count: 0,
600
+ capability_list_warning_count: 0,
601
+ by_doc_file: {},
602
+ frontmatter_errors: [],
603
+ stale: [],
604
+ invalid: [],
605
+ placeholder_errors: [],
606
+ unfinalized_refs: [],
607
+ inline_code_errors: [],
608
+ capability_list_warnings: [],
609
+ };
610
+
611
+ // Find markdown files
612
+ const mdFiles = findMarkdownFiles(docsPath, false, options?.excludePaths ?? []);
613
+ result.total_files = mdFiles.length;
614
+
615
+ if (mdFiles.length === 0) {
616
+ result.message = "No documentation files found";
617
+ return result;
618
+ }
619
+
620
+ // Load validation cache if enabled
621
+ const useCache = options?.useCache ?? false;
622
+ const cache = useCache ? loadValidationCache(projectRoot) : null;
623
+ const newCache: ValidationCache = {
624
+ lastRun: new Date().toISOString(),
625
+ docChecksums: {},
626
+ docIssues: {},
627
+ refFileHashes: {},
628
+ };
629
+
630
+ // Generate ctags index (or use provided one)
631
+ const ctagsIndex =
632
+ options?.ctagsIndex ||
633
+ generateCtagsIndex(projectRoot).index;
634
+
635
+ // Helper to get/create doc file entry
636
+ const getDocEntry = (docFile: string): DocFileIssues => {
637
+ if (!result.by_doc_file[docFile]) {
638
+ result.by_doc_file[docFile] = {
639
+ stale: [],
640
+ invalid: [],
641
+ frontmatter_error: null,
642
+ placeholder_errors: [],
643
+ unfinalized_refs: [],
644
+ inline_code_block_count: 0,
645
+ has_capability_list_warning: false,
646
+ };
647
+ }
648
+ return result.by_doc_file[docFile];
649
+ };
650
+
651
+ // Collect all refs
652
+ const allRefs: ParsedRef[] = [];
653
+ const skippedFromCache: string[] = [];
654
+
655
+ // Process each markdown file
656
+ for (const mdFile of mdFiles) {
657
+ const content = readFileSync(mdFile, "utf-8");
658
+ const relPath = relative(projectRoot, mdFile);
659
+ const contentHash = getContentHash(content);
660
+
661
+ // Check if we can use cached result
662
+ if (cache && cache.docChecksums[relPath] === contentHash) {
663
+ // Doc unchanged - check if referenced files also unchanged
664
+ const cachedIssues = cache.docIssues[relPath];
665
+ if (cachedIssues) {
666
+ // Use cached issues for this file
667
+ result.by_doc_file[relPath] = cachedIssues;
668
+ if (cachedIssues.frontmatter_error) {
669
+ result.frontmatter_errors.push({ doc_file: relPath, reason: cachedIssues.frontmatter_error });
670
+ result.frontmatter_error_count++;
671
+ }
672
+ result.stale_count += cachedIssues.stale.length;
673
+ result.invalid_count += cachedIssues.invalid.length;
674
+ if (cachedIssues.inline_code_block_count > 0) {
675
+ result.inline_code_error_count++;
676
+ }
677
+ if (cachedIssues.has_capability_list_warning) {
678
+ result.capability_list_warning_count++;
679
+ }
680
+ skippedFromCache.push(relPath);
681
+ newCache.docChecksums[relPath] = contentHash;
682
+ newCache.docIssues[relPath] = cachedIssues;
683
+ continue;
684
+ }
685
+ }
686
+
687
+ // Store checksum for cache
688
+ newCache.docChecksums[relPath] = contentHash;
689
+
690
+ // Validate front matter
691
+ const fmResult = validateFrontMatter(content);
692
+ if (!fmResult.valid) {
693
+ result.frontmatter_errors.push({ doc_file: relPath, reason: fmResult.error! });
694
+ getDocEntry(relPath).frontmatter_error = fmResult.error!;
695
+ result.frontmatter_error_count++;
696
+ }
697
+
698
+ // Extract refs
699
+ const refs = extractRefs(content, relPath);
700
+ allRefs.push(...refs);
701
+
702
+ // Detect placeholders (fake hashes)
703
+ const placeholders = detectPlaceholders(content);
704
+ if (placeholders.length > 0) {
705
+ result.placeholder_errors.push({
706
+ doc_file: relPath,
707
+ count: placeholders.length,
708
+ examples: placeholders.slice(0, 3),
709
+ reason: "Placeholder hashes detected - use format-reference command",
710
+ });
711
+ getDocEntry(relPath).placeholder_errors = placeholders;
712
+ result.placeholder_error_count++;
713
+ }
714
+
715
+ // Detect unfinalized refs (refs without hashes)
716
+ const unfinalizedRefs = detectUnfinalizedRefs(content);
717
+ if (unfinalizedRefs.length > 0) {
718
+ result.unfinalized_refs.push({
719
+ doc_file: relPath,
720
+ count: unfinalizedRefs.length,
721
+ examples: unfinalizedRefs.slice(0, 3),
722
+ reason: "Unfinalized refs detected - run 'ah docs finalize'",
723
+ });
724
+ getDocEntry(relPath).unfinalized_refs = unfinalizedRefs;
725
+ result.unfinalized_ref_count++;
726
+ }
727
+
728
+ // Count code blocks
729
+ const codeBlockCount = countCodeBlocks(content);
730
+ if (codeBlockCount > 0) {
731
+ result.inline_code_errors.push({
732
+ doc_file: relPath,
733
+ block_count: codeBlockCount,
734
+ reason: "Documentation contains inline code blocks",
735
+ });
736
+ getDocEntry(relPath).inline_code_block_count = codeBlockCount;
737
+ result.inline_code_error_count++;
738
+ }
739
+
740
+ // Check for capability lists
741
+ if (hasCapabilityList(content)) {
742
+ result.capability_list_warnings.push({
743
+ doc_file: relPath,
744
+ reason: "Possible capability list table detected",
745
+ });
746
+ getDocEntry(relPath).has_capability_list_warning = true;
747
+ result.capability_list_warning_count++;
748
+ }
749
+ }
750
+
751
+ result.total_refs = allRefs.length;
752
+ result.symbol_refs = allRefs.filter((r) => !r.isFileOnly).length;
753
+ result.file_only_refs = allRefs.filter((r) => r.isFileOnly).length;
754
+
755
+ // Batch fetch all file hashes upfront (major performance improvement)
756
+ const uniqueFiles = [...new Set(allRefs.map((r) => r.file))];
757
+ const absoluteFiles = uniqueFiles.map((f) => join(projectRoot, f));
758
+ const hashCache = batchGetBlobHashes(absoluteFiles, projectRoot);
759
+
760
+ // Validate each reference (using cached hashes)
761
+ for (const ref of allRefs) {
762
+ const validated = validateRef(ref, ctagsIndex, projectRoot, hashCache);
763
+
764
+ if (validated.state === "valid") {
765
+ result.valid_count++;
766
+ } else if (validated.state === "stale") {
767
+ result.stale_count++;
768
+ result.stale.push({
769
+ doc_file: ref.docFile,
770
+ reference: ref.reference,
771
+ stored_hash: ref.hash,
772
+ current_hash: validated.currentHash!,
773
+ ref_type: ref.isFileOnly ? "file-only" : "symbol",
774
+ });
775
+ getDocEntry(ref.docFile).stale.push({
776
+ reference: ref.reference,
777
+ file_path: ref.file,
778
+ symbol_name: ref.symbol,
779
+ stored_hash: ref.hash,
780
+ current_hash: validated.currentHash!,
781
+ ref_type: ref.isFileOnly ? "file-only" : "symbol",
782
+ });
783
+ } else {
784
+ result.invalid_count++;
785
+ result.invalid.push({
786
+ doc_file: ref.docFile,
787
+ reference: ref.reference,
788
+ reason: validated.reason!,
789
+ });
790
+ getDocEntry(ref.docFile).invalid.push({
791
+ reference: ref.reference,
792
+ reason: validated.reason!,
793
+ });
794
+ }
795
+ }
796
+
797
+ // Filter by_doc_file to only include docs with issues
798
+ const filteredByDocFile: Record<string, DocFileIssues> = {};
799
+ for (const [docFile, issues] of Object.entries(result.by_doc_file)) {
800
+ const hasIssues =
801
+ issues.stale.length > 0 ||
802
+ issues.invalid.length > 0 ||
803
+ issues.frontmatter_error !== null ||
804
+ issues.placeholder_errors.length > 0 ||
805
+ issues.unfinalized_refs.length > 0 ||
806
+ issues.inline_code_block_count > 0 ||
807
+ issues.has_capability_list_warning;
808
+ if (hasIssues) {
809
+ filteredByDocFile[docFile] = issues;
810
+ }
811
+ }
812
+ result.by_doc_file = filteredByDocFile;
813
+
814
+ // Generate message
815
+ const hasErrors =
816
+ result.frontmatter_error_count > 0 ||
817
+ result.stale_count > 0 ||
818
+ result.invalid_count > 0 ||
819
+ result.placeholder_error_count > 0 ||
820
+ result.unfinalized_ref_count > 0;
821
+
822
+ if (hasErrors) {
823
+ const parts: string[] = [];
824
+ if (result.frontmatter_error_count > 0)
825
+ parts.push(`${result.frontmatter_error_count} front matter errors`);
826
+ if (result.invalid_count > 0)
827
+ parts.push(`${result.invalid_count} invalid refs`);
828
+ if (result.stale_count > 0) parts.push(`${result.stale_count} stale refs`);
829
+ if (result.placeholder_error_count > 0)
830
+ parts.push(`${result.placeholder_error_count} placeholder hashes`);
831
+ if (result.unfinalized_ref_count > 0)
832
+ parts.push(`${result.unfinalized_ref_count} unfinalized refs`);
833
+ result.message = `Validation found issues: ${parts.join(", ")}`;
834
+ } else if (result.capability_list_warning_count > 0) {
835
+ result.message = `Validated ${result.total_files} files with ${result.capability_list_warning_count} warnings`;
836
+ } else {
837
+ result.message = `Validated ${result.total_files} files and ${result.total_refs} references (${result.symbol_refs} symbol, ${result.file_only_refs} file-only)`;
838
+ }
839
+
840
+ // Add cache info to message if applicable
841
+ if (skippedFromCache.length > 0) {
842
+ result.message += ` (${skippedFromCache.length} from cache)`;
843
+ }
844
+
845
+ // Save cache for future runs
846
+ if (useCache) {
847
+ // Store validated doc issues in cache
848
+ for (const [docPath, issues] of Object.entries(result.by_doc_file)) {
849
+ newCache.docIssues[docPath] = issues;
850
+ }
851
+ saveValidationCache(projectRoot, newCache);
852
+ }
853
+
854
+ return result;
855
+ }
856
+
857
+ /**
858
+ * Validate all documentation in a directory (async version).
859
+ *
860
+ * Identical to validateDocs but uses generateCtagsIndexAsync to avoid
861
+ * blocking the event loop during ctags execution.
862
+ */
863
+ export async function validateDocsAsync(
864
+ docsPath: string,
865
+ projectRoot: string,
866
+ options?: { ctagsIndex?: CtagsIndex; useCache?: boolean; excludePaths?: string[] }
867
+ ): Promise<ValidationResult> {
868
+ // If a ctags index was provided, delegate to the sync version directly
869
+ if (options?.ctagsIndex) {
870
+ return validateDocs(docsPath, projectRoot, options);
871
+ }
872
+
873
+ // Generate ctags index asynchronously
874
+ const ctagsResult = await generateCtagsIndexAsync(projectRoot);
875
+
876
+ // If ctags is unavailable or failed, run validation with the empty map
877
+ // (to prevent a sync fallback that would also fail), then strip out the
878
+ // symbol-ref invalids that are false positives from the empty index.
879
+ if (!ctagsResult.success) {
880
+ const baseResult = validateDocs(docsPath, projectRoot, {
881
+ ...options,
882
+ ctagsIndex: ctagsResult.index, // empty map — avoids sync fallback
883
+ });
884
+ // Remove symbol-ref invalid entries (they're false positives from empty index)
885
+ const symbolInvalidCount = baseResult.invalid.filter((i) =>
886
+ i.reason.startsWith("Symbol '")
887
+ ).length;
888
+ baseResult.invalid = baseResult.invalid.filter(
889
+ (i) => !i.reason.startsWith("Symbol '")
890
+ );
891
+ // Also strip from per-doc entries
892
+ for (const entry of Object.values(baseResult.by_doc_file)) {
893
+ entry.invalid = entry.invalid.filter(
894
+ (i) => !i.reason.startsWith("Symbol '")
895
+ );
896
+ }
897
+ baseResult.invalid_count -= symbolInvalidCount;
898
+ baseResult.message = `ctags unavailable: ${ctagsResult.error ?? "unknown error"} — symbol ref validation skipped`;
899
+ return baseResult;
900
+ }
901
+
902
+ // Delegate to sync validateDocs with the pre-built index
903
+ return validateDocs(docsPath, projectRoot, {
904
+ ...options,
905
+ ctagsIndex: ctagsResult.index,
906
+ });
907
+ }