pi-ui-extend 1.0.42 → 1.0.45

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 (233) hide show
  1. package/README.md +41 -12
  2. package/dist/app/app.d.ts +10 -0
  3. package/dist/app/app.js +132 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +3 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +51 -34
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +14 -0
  17. package/dist/app/commands/reload-context-inventory.js +12 -1
  18. package/dist/app/input/autocomplete-controller.js +61 -34
  19. package/dist/app/input/input-action-controller.d.ts +2 -0
  20. package/dist/app/input/input-action-controller.js +8 -1
  21. package/dist/app/input/input-controller.d.ts +2 -1
  22. package/dist/app/input/input-controller.js +7 -2
  23. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  24. package/dist/app/input/voice-controller.d.ts +41 -45
  25. package/dist/app/input/voice-controller.js +351 -419
  26. package/dist/app/model/model-usage-controller.js +17 -7
  27. package/dist/app/model/model-usage-status.d.ts +4 -1
  28. package/dist/app/model/model-usage-status.js +270 -46
  29. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  30. package/dist/app/popup/menu-items-controller.js +37 -21
  31. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  32. package/dist/app/popup/popup-action-controller.js +77 -24
  33. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  34. package/dist/app/popup/popup-menu-controller.js +239 -69
  35. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  36. package/dist/app/rendering/dcp-stats.js +40 -425
  37. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  38. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  39. package/dist/app/rendering/render-controller.js +23 -2
  40. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  41. package/dist/app/rendering/status-line-renderer.js +41 -5
  42. package/dist/app/rendering/tab-line-renderer.js +26 -20
  43. package/dist/app/runtime.d.ts +12 -1
  44. package/dist/app/runtime.js +120 -12
  45. package/dist/app/screen/mouse-controller.d.ts +2 -0
  46. package/dist/app/screen/mouse-controller.js +17 -7
  47. package/dist/app/screen/status-controller.d.ts +4 -0
  48. package/dist/app/screen/status-controller.js +5 -0
  49. package/dist/app/session/lazy-session-manager.js +34 -0
  50. package/dist/app/session/session-event-controller.d.ts +1 -0
  51. package/dist/app/session/session-event-controller.js +10 -1
  52. package/dist/app/session/session-history.d.ts +1 -0
  53. package/dist/app/session/session-history.js +12 -1
  54. package/dist/app/session/session-lifecycle-controller.d.ts +6 -1
  55. package/dist/app/session/session-lifecycle-controller.js +10 -1
  56. package/dist/app/session/tabs-controller.d.ts +25 -1
  57. package/dist/app/session/tabs-controller.js +442 -153
  58. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  59. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  60. package/dist/app/types.d.ts +11 -1
  61. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  62. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  63. package/dist/app/workspace/workspace-undo.js +1 -0
  64. package/dist/bundled-extensions/question/index.js +9 -1
  65. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  66. package/dist/bundled-extensions/question/remote.js +33 -0
  67. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  68. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  69. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  70. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  71. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  72. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  73. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  74. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  75. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  76. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  77. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  78. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  79. package/dist/config.d.ts +21 -2
  80. package/dist/config.js +159 -32
  81. package/dist/default-pix-config.js +20 -10
  82. package/dist/schemas/pi-tools-suite-schema.d.ts +8 -4
  83. package/dist/schemas/pi-tools-suite-schema.js +11 -5
  84. package/dist/schemas/pix-schema.d.ts +12 -2
  85. package/dist/schemas/pix-schema.js +37 -9
  86. package/dist/schemas/tasks-schema.js +1 -1
  87. package/docs/concurrency.md +3 -1
  88. package/docs/desktop-mvp.md +10 -5
  89. package/docs/desktop-task-manager.md +81 -28
  90. package/docs/release.md +30 -6
  91. package/external/pi-tools-suite/README.md +165 -39
  92. package/external/pi-tools-suite/docs/session-recovery.md +7 -0
  93. package/external/pi-tools-suite/docs/subagent-model-pools.md +12 -5
  94. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  95. package/external/pi-tools-suite/package.json +6 -1
  96. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  97. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  98. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  99. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  100. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  101. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  102. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  103. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  104. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  105. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  106. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  107. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  108. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  109. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  110. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  111. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  112. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  113. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  114. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  115. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  116. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  117. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  119. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  127. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  128. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  129. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +0 -2
  130. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  131. package/external/pi-tools-suite/src/async-subagents/core/config.ts +40 -17
  132. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  133. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  134. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +7 -4
  135. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +34 -24
  136. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  137. package/external/pi-tools-suite/src/async-subagents/lib.ts +9 -1
  138. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +0 -1
  139. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
  140. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  141. package/external/pi-tools-suite/src/config.ts +14 -0
  142. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  143. package/external/pi-tools-suite/src/context-gateway/config.ts +66 -1
  144. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  145. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +123 -3
  146. package/external/pi-tools-suite/src/context-gateway/index.ts +137 -4
  147. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +5 -5
  148. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +2 -2
  149. package/external/pi-tools-suite/src/context-gateway/types.ts +8 -1
  150. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  151. package/external/pi-tools-suite/src/dcp/auto-compress.ts +41 -14
  152. package/external/pi-tools-suite/src/dcp/commands.ts +14 -156
  153. package/external/pi-tools-suite/src/dcp/compress-tool.ts +8 -2
  154. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  155. package/external/pi-tools-suite/src/dcp/config.ts +58 -4
  156. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  157. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  158. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  159. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  160. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  161. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  162. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +92 -2
  163. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  164. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  165. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  166. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  167. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  168. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  169. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +68 -16
  170. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  171. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  172. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  173. package/external/pi-tools-suite/src/resource-registry/index.ts +282 -21
  174. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  175. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  176. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  177. package/external/pi-tools-suite/src/tool-descriptions.ts +44 -15
  178. package/package.json +7 -8
  179. package/schemas/pi-tools-suite.json +44 -12
  180. package/schemas/pix.json +109 -15
  181. package/schemas/tasks.json +1 -2
  182. package/skills/simplify/SKILL.md +33 -5
  183. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -196
  184. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  185. package/skills/playwright-cli/SKILL.md +0 -420
  186. package/skills/playwright-cli/references/element-attributes.md +0 -23
  187. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  188. package/skills/playwright-cli/references/request-mocking.md +0 -87
  189. package/skills/playwright-cli/references/running-code.md +0 -241
  190. package/skills/playwright-cli/references/session-management.md +0 -273
  191. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  192. package/skills/playwright-cli/references/storage-state.md +0 -290
  193. package/skills/playwright-cli/references/test-generation.md +0 -142
  194. package/skills/playwright-cli/references/tracing.md +0 -154
  195. package/skills/playwright-cli/references/video-recording.md +0 -147
  196. package/skills/spec-wiki/SKILL.md +0 -55
  197. package/skills/spec-wiki/evals/evals.json +0 -132
  198. package/skills/spec-wiki/evals/files/bootstrap/architecture/rfc-007.md +0 -14
  199. package/skills/spec-wiki/evals/files/bootstrap/docs/how-to-pay.md +0 -4
  200. package/skills/spec-wiki/evals/files/bootstrap/docs/overview.md +0 -9
  201. package/skills/spec-wiki/evals/files/bootstrap/requirements/payment-behavior.md +0 -17
  202. package/skills/spec-wiki/evals/files/bootstrap/src/payments.ts +0 -12
  203. package/skills/spec-wiki/evals/files/bootstrap/tests/payments.test.ts +0 -11
  204. package/skills/spec-wiki/evals/files/focused/src/session.ts +0 -5
  205. package/skills/spec-wiki/evals/files/focused/tests/session.test.ts +0 -17
  206. package/skills/spec-wiki/evals/files/maintenance/low-signal/incoming/capture.txt +0 -2
  207. package/skills/spec-wiki/evals/files/maintenance/low-signal/src/capture.ts +0 -3
  208. package/skills/spec-wiki/evals/files/maintenance/move/docs/old-job-contract.md +0 -18
  209. package/skills/spec-wiki/evals/files/maintenance/move/src/job.ts +0 -5
  210. package/skills/spec-wiki/evals/files/maintenance/new-relation/docs/session-refresh.md +0 -24
  211. package/skills/spec-wiki/evals/files/maintenance/new-relation/incoming/refresh-worker.ts +0 -7
  212. package/skills/spec-wiki/evals/files/maintenance/new-relation/src/session.ts +0 -7
  213. package/skills/spec-wiki/evals/files/maintenance/no-impact/docs/auth-contract.md +0 -17
  214. package/skills/spec-wiki/evals/files/maintenance/no-impact/incoming/telemetry-format.ts +0 -3
  215. package/skills/spec-wiki/evals/files/maintenance/no-impact/src/auth.ts +0 -3
  216. package/skills/spec-wiki/evals/files/query/docs/how-to-run-jobs.md +0 -4
  217. package/skills/spec-wiki/evals/files/query/docs/spec-overview.md +0 -9
  218. package/skills/spec-wiki/evals/files/query/features/jobs/cancellation-contract.md +0 -15
  219. package/skills/spec-wiki/evals/files/query/features/jobs/retry-spec.md +0 -6
  220. package/skills/spec-wiki/evals/maintenance_regression.py +0 -311
  221. package/skills/spec-wiki/evals/retrieval-eval.md +0 -37
  222. package/skills/spec-wiki/evals/retrieval-evals.json +0 -37
  223. package/skills/spec-wiki/evals/trigger-evals.json +0 -94
  224. package/skills/spec-wiki/references/bootstrap.md +0 -100
  225. package/skills/spec-wiki/references/focused-spec.md +0 -117
  226. package/skills/spec-wiki/references/index-schema.md +0 -330
  227. package/skills/spec-wiki/references/maintenance.md +0 -134
  228. package/skills/spec-wiki/references/query.md +0 -74
  229. package/skills/spec-wiki/scripts/spec_wiki.py +0 -2076
  230. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  231. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  232. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -1,2076 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Deterministic storage/discovery layer for the spec-wiki skill."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import fnmatch
8
- import hashlib
9
- import json
10
- import os
11
- import re
12
- import subprocess
13
- import sys
14
- import tempfile
15
- import unicodedata
16
- from datetime import datetime, timezone
17
- from pathlib import Path
18
- from typing import Any, Iterable
19
-
20
-
21
- SCHEMA_VERSION = 2
22
- SPEC_CLASSIFICATIONS = {"spec", "spec-like"}
23
- CLASSIFICATIONS = SPEC_CLASSIFICATIONS | {"meta-index", "design-only", "guide", "other"}
24
- SPEC_TYPES = {"as-is", "change", "mixed", "unknown"}
25
- CONFIDENCES = {"high", "medium", "low", "unknown"}
26
- LIFECYCLES = {"active", "proposed", "historical", "superseded", "unknown"}
27
- SPEC_RELATION_KINDS = {"related", "supersedes", "superseded-by"}
28
- DOC_EXTENSIONS = {".md", ".mdx", ".rst", ".adoc", ".txt"}
29
- DEFAULT_IGNORED_DIRS = {
30
- ".git",
31
- ".hg",
32
- ".svn",
33
- ".spec-wiki",
34
- ".idea",
35
- ".vscode",
36
- ".venv",
37
- "venv",
38
- "node_modules",
39
- "vendor",
40
- "dist",
41
- "build",
42
- "coverage",
43
- ".next",
44
- ".nuxt",
45
- ".cache",
46
- "__pycache__",
47
- }
48
- DEFAULT_CONFIG: dict[str, Any] = {
49
- "includeExtensions": sorted(DOC_EXTENSIONS),
50
- "include": [],
51
- "exclude": [],
52
- "forceSpecs": [],
53
- "ignoreSpecs": [],
54
- "minCandidateScore": 3,
55
- "maxFileBytes": 512 * 1024,
56
- }
57
-
58
- SPEC_NAME_RE = re.compile(r"(?:^|[-_.])(spec|contract|requirements?|rfc|protocol|behavior)(?:[-_.]|$)", re.I)
59
- SPEC_DIR_NAMES = {"spec", "specs", "contracts", "requirements", "rfcs", "rfc", "protocols"}
60
- FIXTURE_DIR_NAMES = {"evals", "fixtures", "fixture", "examples", "example", "testdata", "samples", "sample"}
61
- SKILL_RESOURCE_DIR_NAMES = {"skills", "agents"}
62
- DESIGN_NAME_RE = re.compile(r"(?:^|[-_.])(adr|design|decision|evidence|measurement|review)(?:[-_.]|$)", re.I)
63
- SPEC_HEADING_RE = re.compile(
64
- r"^(?:#+\s*)?(?:"
65
- r"behavior|behaviour|requirements?|contracts?|invariants?|scope|non-goals?|"
66
- r"acceptance(?: criteria)?|expected behavior|verification|edge cases?|public contracts?|"
67
- r"current behavior|intended behavior|semantics|compatibility"
68
- r")\s*:?[\s#]*$",
69
- re.I,
70
- )
71
- META_NAME_RE = re.compile(r"^(?:\d+[-_.])?(?:readme|index|overview|toc|catalog|contents?)(?:[-_.].*)?$", re.I)
72
- META_TEXT_RE = re.compile(r"\b(?:specs? index|spec inventory|full spec inventory|table of contents|documentation index|catalog of specs)\b", re.I)
73
- TYPE_AS_IS_RE = re.compile(r"\b(?:as-is|current behavior|current behaviour)\b", re.I)
74
- TYPE_CHANGE_RE = re.compile(r"\b(?:change spec|intended behavior|intended behaviour|proposed behavior|target behavior)\b", re.I)
75
- MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
76
- BACKTICK_RE = re.compile(r"`([^`\n]+)`")
77
- PLAIN_PATH_RE = re.compile(r"(?<![\w/])(?:[A-Za-z0-9_.@+-]+/)+[A-Za-z0-9_.@+\-]+\.[A-Za-z0-9]{1,12}")
78
- SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
79
- RELATIVE_BASE_RE = re.compile(
80
- r"(?:paths?|references?|files?)\b[^\n]{0,80}?\brelative\s+to\s+`([^`]+)`",
81
- re.I,
82
- )
83
-
84
-
85
- def utc_now() -> str:
86
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
87
-
88
-
89
- def sha256_bytes(data: bytes) -> str:
90
- return "sha256:" + hashlib.sha256(data).hexdigest()
91
-
92
-
93
- def file_hash(path: Path) -> str | None:
94
- try:
95
- digest = hashlib.sha256()
96
- with path.open("rb") as handle:
97
- while chunk := handle.read(1024 * 1024):
98
- digest.update(chunk)
99
- return "sha256:" + digest.hexdigest()
100
- except (OSError, PermissionError):
101
- return None
102
-
103
-
104
- def normalize_rel_path(root: Path, value: str | Path) -> str:
105
- raw = Path(value)
106
- absolute = raw if raw.is_absolute() else root / raw
107
- resolved = absolute.resolve(strict=False)
108
- try:
109
- relative = resolved.relative_to(root.resolve())
110
- except ValueError as exc:
111
- raise ValueError(f"path escapes project root: {value}") from exc
112
- return relative.as_posix()
113
-
114
-
115
- def safe_project_path(root: Path, rel: str) -> Path:
116
- normalized = normalize_rel_path(root, rel)
117
- return root / normalized
118
-
119
-
120
- def strip_jsonc(text: str) -> str:
121
- output: list[str] = []
122
- i = 0
123
- in_string = False
124
- escaped = False
125
- while i < len(text):
126
- char = text[i]
127
- nxt = text[i + 1] if i + 1 < len(text) else ""
128
- if in_string:
129
- output.append(char)
130
- if escaped:
131
- escaped = False
132
- elif char == "\\":
133
- escaped = True
134
- elif char == '"':
135
- in_string = False
136
- i += 1
137
- continue
138
- if char == '"':
139
- in_string = True
140
- output.append(char)
141
- i += 1
142
- continue
143
- if char == "/" and nxt == "/":
144
- i += 2
145
- while i < len(text) and text[i] not in "\r\n":
146
- i += 1
147
- continue
148
- if char == "/" and nxt == "*":
149
- i += 2
150
- while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
151
- i += 1
152
- i += 2
153
- continue
154
- output.append(char)
155
- i += 1
156
- cleaned = "".join(output)
157
- output = []
158
- in_string = False
159
- escaped = False
160
- for index, char in enumerate(cleaned):
161
- if in_string:
162
- output.append(char)
163
- if escaped:
164
- escaped = False
165
- elif char == "\\":
166
- escaped = True
167
- elif char == '"':
168
- in_string = False
169
- continue
170
- if char == '"':
171
- in_string = True
172
- output.append(char)
173
- continue
174
- if char == ",":
175
- cursor = index + 1
176
- while cursor < len(cleaned) and cleaned[cursor].isspace():
177
- cursor += 1
178
- if cursor < len(cleaned) and cleaned[cursor] in "}]":
179
- continue
180
- output.append(char)
181
- return "".join(output)
182
-
183
-
184
- def detect_root(explicit: str | None) -> Path:
185
- if explicit:
186
- return Path(explicit).expanduser().resolve()
187
- try:
188
- proc = subprocess.run(
189
- ["git", "rev-parse", "--show-toplevel"],
190
- check=True,
191
- stdout=subprocess.PIPE,
192
- stderr=subprocess.DEVNULL,
193
- text=True,
194
- timeout=5,
195
- )
196
- value = proc.stdout.strip()
197
- if value:
198
- return Path(value).resolve()
199
- except (FileNotFoundError, subprocess.SubprocessError):
200
- pass
201
- return Path.cwd().resolve()
202
-
203
-
204
- def resolve_state_dir(root: Path, value: str | None) -> Path:
205
- if not value:
206
- candidate = root / ".spec-wiki"
207
- if candidate.is_symlink():
208
- raise ValueError("default .spec-wiki directory must not be a symlink")
209
- return candidate
210
- candidate = Path(value).expanduser()
211
- if candidate.is_absolute():
212
- return candidate.resolve()
213
- resolved = (root / candidate).resolve(strict=False)
214
- try:
215
- resolved.relative_to(root.resolve())
216
- except ValueError as exc:
217
- raise ValueError(f"relative --state-dir escapes project root: {value}") from exc
218
- return resolved
219
-
220
-
221
- def load_config(root: Path, state_dir: Path, explicit: str | None) -> dict[str, Any]:
222
- config = dict(DEFAULT_CONFIG)
223
- config_path = Path(explicit).expanduser() if explicit else state_dir / "config.jsonc"
224
- if not config_path.is_absolute():
225
- config_path = root / config_path
226
- if not config_path.exists():
227
- fallback = config_path.with_suffix(".json") if config_path.suffix == ".jsonc" else None
228
- if not fallback or not fallback.exists():
229
- return config
230
- config_path = fallback
231
- try:
232
- parsed = json.loads(strip_jsonc(config_path.read_text(encoding="utf-8")))
233
- except (OSError, json.JSONDecodeError) as exc:
234
- raise ValueError(f"failed to parse config {config_path}: {exc}") from exc
235
- if not isinstance(parsed, dict):
236
- raise ValueError(f"config must be a JSON object: {config_path}")
237
- config.update(parsed)
238
- for key in ("includeExtensions", "include", "exclude", "forceSpecs", "ignoreSpecs"):
239
- value = config.get(key)
240
- if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
241
- raise ValueError(f"config.{key} must be an array of strings")
242
- for key in ("minCandidateScore", "maxFileBytes"):
243
- value = config.get(key)
244
- if not isinstance(value, int) or isinstance(value, bool) or value < 0:
245
- raise ValueError(f"config.{key} must be a non-negative integer")
246
- if config["maxFileBytes"] == 0:
247
- raise ValueError("config.maxFileBytes must be greater than zero")
248
- return config
249
-
250
-
251
- def state_file(state_dir: Path) -> Path:
252
- return state_dir / "state.json"
253
-
254
-
255
- def index_file(state_dir: Path) -> Path:
256
- return state_dir / "index.md"
257
-
258
-
259
- def empty_state() -> dict[str, Any]:
260
- return {"schemaVersion": SCHEMA_VERSION, "entries": {}}
261
-
262
-
263
- def load_state(state_dir: Path) -> dict[str, Any]:
264
- path = state_file(state_dir)
265
- if not path.exists():
266
- return empty_state()
267
- try:
268
- state = json.loads(path.read_text(encoding="utf-8"))
269
- except (OSError, json.JSONDecodeError) as exc:
270
- raise ValueError(f"failed to parse state {path}: {exc}") from exc
271
- if not isinstance(state, dict):
272
- raise ValueError("state.json must contain a JSON object")
273
- return state
274
-
275
-
276
- def atomic_write_text(path: Path, text: str) -> None:
277
- path.parent.mkdir(parents=True, exist_ok=True)
278
- fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
279
- try:
280
- with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
281
- handle.write(text)
282
- handle.flush()
283
- os.fsync(handle.fileno())
284
- os.replace(temp_name, path)
285
- finally:
286
- if os.path.exists(temp_name):
287
- os.unlink(temp_name)
288
-
289
-
290
- def save_state(state_dir: Path, state: dict[str, Any]) -> None:
291
- payload = json.dumps(state, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
292
- atomic_write_text(state_file(state_dir), payload)
293
-
294
-
295
- def matches_any(path: str, patterns: Iterable[str]) -> bool:
296
- return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
297
-
298
-
299
- def is_under_ignored_dir(path: str, state_dir: Path, root: Path) -> bool:
300
- parts = Path(path).parts
301
- if any(part in DEFAULT_IGNORED_DIRS for part in parts[:-1]):
302
- return True
303
- try:
304
- state_rel = state_dir.resolve().relative_to(root.resolve()).as_posix()
305
- except ValueError:
306
- return False
307
- return path == state_rel or path.startswith(state_rel.rstrip("/") + "/")
308
-
309
-
310
- def git_project_files(root: Path) -> list[str] | None:
311
- try:
312
- proc = subprocess.run(
313
- ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
314
- cwd=root,
315
- stdout=subprocess.PIPE,
316
- stderr=subprocess.DEVNULL,
317
- check=True,
318
- timeout=30,
319
- )
320
- except (FileNotFoundError, subprocess.SubprocessError):
321
- return None
322
- return sorted({item.decode("utf-8", errors="surrogateescape") for item in proc.stdout.split(b"\0") if item})
323
-
324
-
325
- def git_changed_records(root: Path, base: str = "HEAD") -> list[dict[str, str]]:
326
- try:
327
- subprocess.run(
328
- ["git", "rev-parse", "--is-inside-work-tree"],
329
- cwd=root,
330
- stdout=subprocess.DEVNULL,
331
- stderr=subprocess.DEVNULL,
332
- check=True,
333
- timeout=5,
334
- )
335
- except (FileNotFoundError, subprocess.SubprocessError) as exc:
336
- raise ValueError("Git working tree is required when impact paths are not supplied explicitly") from exc
337
-
338
- records: list[dict[str, str]] = []
339
- try:
340
- proc = subprocess.run(
341
- ["git", "diff", "--name-status", "-z", "--find-renames", base, "--"],
342
- cwd=root,
343
- stdout=subprocess.PIPE,
344
- stderr=subprocess.PIPE,
345
- check=True,
346
- timeout=30,
347
- )
348
- except subprocess.CalledProcessError as exc:
349
- message = exc.stderr.decode("utf-8", errors="replace").strip()
350
- raise ValueError(f"failed to inspect Git changes against {base}: {message or exc}") from exc
351
-
352
- parts = [part.decode("utf-8", errors="surrogateescape") for part in proc.stdout.split(b"\0") if part]
353
- index = 0
354
- while index < len(parts):
355
- status = parts[index]
356
- index += 1
357
- code = status[:1]
358
- if code in {"R", "C"}:
359
- if index + 1 >= len(parts):
360
- break
361
- old_path, new_path = parts[index], parts[index + 1]
362
- index += 2
363
- records.append({"status": code, "oldPath": old_path.replace("\\", "/"), "path": new_path.replace("\\", "/")})
364
- else:
365
- if index >= len(parts):
366
- break
367
- path = parts[index]
368
- index += 1
369
- records.append({"status": code or "M", "path": path.replace("\\", "/")})
370
-
371
- try:
372
- untracked = subprocess.run(
373
- ["git", "ls-files", "--others", "--exclude-standard", "-z"],
374
- cwd=root,
375
- stdout=subprocess.PIPE,
376
- stderr=subprocess.DEVNULL,
377
- check=True,
378
- timeout=30,
379
- )
380
- tracked_paths = {
381
- item.get("path") for item in records if isinstance(item.get("path"), str)
382
- }
383
- for raw in untracked.stdout.split(b"\0"):
384
- if not raw:
385
- continue
386
- path = raw.decode("utf-8", errors="surrogateescape").replace("\\", "/")
387
- if path not in tracked_paths:
388
- records.append({"status": "?", "path": path})
389
- except (FileNotFoundError, subprocess.SubprocessError):
390
- pass
391
-
392
- return sorted(records, key=lambda item: (str(item.get("path", "")), str(item.get("oldPath", "")), str(item.get("status", ""))))
393
-
394
-
395
- def changed_record_paths(records: Iterable[dict[str, str]]) -> list[str]:
396
- paths: set[str] = set()
397
- for record in records:
398
- for key in ("oldPath", "path"):
399
- value = record.get(key)
400
- if isinstance(value, str) and value:
401
- paths.add(value.replace("\\", "/"))
402
- return sorted(paths)
403
-
404
-
405
- def filter_internal_change_records(
406
- records: Iterable[dict[str, str]],
407
- state_dir: Path,
408
- root: Path,
409
- ) -> list[dict[str, str]]:
410
- result: list[dict[str, str]] = []
411
- for record in records:
412
- values = [record.get(key) for key in ("oldPath", "path") if isinstance(record.get(key), str)]
413
- if values and all(is_under_ignored_dir(value, state_dir, root) for value in values):
414
- continue
415
- result.append(record)
416
- return result
417
-
418
-
419
- def walked_project_files(root: Path) -> list[str]:
420
- files: list[str] = []
421
- for current, dirs, names in os.walk(root):
422
- dirs[:] = sorted(d for d in dirs if d not in DEFAULT_IGNORED_DIRS)
423
- current_path = Path(current)
424
- for name in sorted(names):
425
- try:
426
- files.append((current_path / name).relative_to(root).as_posix())
427
- except ValueError:
428
- continue
429
- return files
430
-
431
-
432
- def project_files(root: Path) -> list[str]:
433
- git_files = git_project_files(root)
434
- return git_files if git_files is not None else walked_project_files(root)
435
-
436
-
437
- def candidate_file_paths(root: Path, state_dir: Path, config: dict[str, Any]) -> list[str]:
438
- extensions = {str(item).lower() if str(item).startswith(".") else f".{str(item).lower()}" for item in config.get("includeExtensions", [])}
439
- includes = [str(item) for item in config.get("include", [])]
440
- excludes = [str(item) for item in config.get("exclude", [])]
441
- forced = [str(item) for item in config.get("forceSpecs", [])]
442
- ignored = [str(item) for item in config.get("ignoreSpecs", [])]
443
- result: list[str] = []
444
- for rel in project_files(root):
445
- rel = rel.replace("\\", "/")
446
- if is_under_ignored_dir(rel, state_dir, root):
447
- continue
448
- if matches_any(rel, ignored) or matches_any(rel, excludes):
449
- continue
450
- is_forced = matches_any(rel, forced)
451
- if not is_forced:
452
- if extensions and Path(rel).suffix.lower() not in extensions:
453
- continue
454
- if includes and not matches_any(rel, includes):
455
- continue
456
- result.append(rel)
457
- return result
458
-
459
-
460
- def read_candidate(path: Path, max_bytes: int) -> tuple[str, bytes] | None:
461
- try:
462
- size = path.stat().st_size
463
- if size > max_bytes:
464
- return None
465
- data = path.read_bytes()
466
- except OSError:
467
- return None
468
- if b"\x00" in data[:8192]:
469
- return None
470
- return data.decode("utf-8", errors="replace"), data
471
-
472
-
473
- def document_title(text: str, rel: str) -> str:
474
- lines = text.splitlines()[:80]
475
- for index, line in enumerate(lines):
476
- match = re.match(r"^\s*(?:#{1,6}|={1,6})\s+(.+?)\s*#*\s*$", line)
477
- if match:
478
- return match.group(1).strip()
479
- if index + 1 < len(lines) and line.strip() and re.fullmatch(r"\s*[=~^-]{3,}\s*", lines[index + 1]):
480
- return line.strip()
481
- return Path(rel).stem.replace("-", " ").replace("_", " ").strip()
482
-
483
-
484
- def discovery_signals(rel: str, text: str, forced: bool = False) -> tuple[int, str, list[str]]:
485
- score = 0
486
- signals: list[str] = []
487
- path = Path(rel)
488
- stem = path.stem
489
- lower_parts = {part.lower() for part in path.parts[:-1]}
490
- if forced:
491
- score += 100
492
- signals.append("forced-by-config")
493
- if SPEC_NAME_RE.search(stem):
494
- score += 4
495
- signals.append("spec-like-filename")
496
- if lower_parts & SPEC_DIR_NAMES:
497
- score += 3
498
- signals.append("spec-like-directory")
499
- noisy_resource_path = bool(lower_parts & (FIXTURE_DIR_NAMES | SKILL_RESOURCE_DIR_NAMES)) and not forced
500
- if lower_parts & FIXTURE_DIR_NAMES and not forced:
501
- score -= 12
502
- signals.append("fixture-like-path")
503
- if lower_parts & SKILL_RESOURCE_DIR_NAMES and not forced:
504
- score -= 12
505
- signals.append("skill-resource-path")
506
- if DESIGN_NAME_RE.search(stem):
507
- score += 3
508
- signals.append("design-like-filename")
509
-
510
- headings = []
511
- for line in text.splitlines()[:500]:
512
- stripped = re.sub(r"^\s*(?:#{1,6}|={1,6})\s+", "", line).strip().rstrip("#").strip()
513
- if SPEC_HEADING_RE.match(stripped):
514
- headings.append(stripped.lower())
515
- unique_headings = sorted(set(headings))
516
- if unique_headings:
517
- score += min(6, len(unique_headings) * 2)
518
- signals.append("contract-headings:" + ",".join(unique_headings[:4]))
519
- if TYPE_AS_IS_RE.search(text[:20000]):
520
- score += 1
521
- signals.append("as-is-language")
522
- if TYPE_CHANGE_RE.search(text[:20000]):
523
- score += 1
524
- signals.append("change-language")
525
-
526
- path_refs = set(PLAIN_PATH_RE.findall(text[:100000]))
527
- if path_refs:
528
- score += 1 if len(path_refs) < 3 else 2
529
- signals.append(f"path-references:{min(len(path_refs), 99)}")
530
-
531
- meta_score = 0
532
- if META_NAME_RE.match(stem):
533
- meta_score += 3
534
- signals.append("meta-like-filename")
535
- if META_TEXT_RE.search(text[:30000]):
536
- meta_score += 4
537
- signals.append("meta-index-language")
538
- doc_links = [target for target in MARKDOWN_LINK_RE.findall(text[:100000]) if Path(target.split("#", 1)[0]).suffix.lower() in DOC_EXTENSIONS]
539
- if len(doc_links) >= 4:
540
- meta_score += 2
541
- signals.append(f"many-doc-links:{min(len(doc_links), 99)}")
542
- title = document_title(text, rel)
543
- if re.search(r"\b(?:specs? index|spec inventory|documentation index)\b", title, re.I):
544
- meta_score += 4
545
- signals.append("meta-like-title")
546
-
547
- if noisy_resource_path:
548
- role_hint = "weak-candidate"
549
- elif meta_score >= 5:
550
- role_hint = "meta-index"
551
- elif score >= 4:
552
- role_hint = "spec-candidate"
553
- else:
554
- role_hint = "weak-candidate"
555
- return score, role_hint, signals
556
-
557
-
558
- def entry_baseline_hash(entry: dict[str, Any]) -> str | None:
559
- indexed = entry.get("indexed")
560
- return indexed.get("sourceHash") if isinstance(indexed, dict) else None
561
-
562
-
563
- def discover_candidates(
564
- root: Path,
565
- state_dir: Path,
566
- config: dict[str, Any],
567
- state: dict[str, Any],
568
- include_all: bool = False,
569
- all_unclassified: bool = False,
570
- ) -> list[dict[str, Any]]:
571
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
572
- max_bytes = int(config.get("maxFileBytes", DEFAULT_CONFIG["maxFileBytes"]))
573
- min_score = int(config.get("minCandidateScore", DEFAULT_CONFIG["minCandidateScore"]))
574
- forced_patterns = [str(item) for item in config.get("forceSpecs", [])]
575
- candidates: list[dict[str, Any]] = []
576
- for rel in candidate_file_paths(root, state_dir, config):
577
- candidate_path = root / rel
578
- if candidate_path.is_symlink():
579
- continue
580
- loaded = read_candidate(candidate_path, max_bytes)
581
- if not loaded:
582
- continue
583
- text, data = loaded
584
- forced = matches_any(rel, forced_patterns)
585
- score, role_hint, signals = discovery_signals(rel, text, forced)
586
- known = entries.get(rel) if isinstance(entries.get(rel), dict) else None
587
- current_hash = sha256_bytes(data)
588
- changed = bool(known and entry_baseline_hash(known) != current_hash)
589
- if all_unclassified:
590
- if known:
591
- continue
592
- if not forced and any(signal in {"fixture-like-path", "skill-resource-path"} for signal in signals):
593
- continue
594
- elif not include_all:
595
- if known and not changed:
596
- continue
597
- if not forced and score < min_score and role_hint != "meta-index":
598
- continue
599
- candidates.append(
600
- {
601
- "path": rel,
602
- "title": document_title(text, rel),
603
- "score": score,
604
- "roleHint": role_hint,
605
- "signals": signals,
606
- "currentHash": current_hash,
607
- "knownClassification": known.get("classification") if known else None,
608
- "changedSinceClassification": changed if known else None,
609
- }
610
- )
611
- return sorted(
612
- candidates,
613
- key=lambda item: (
614
- 0 if item["knownClassification"] is None else 1,
615
- 0 if item["roleHint"] == "spec-candidate" else 1 if item["roleHint"] == "meta-index" else 2,
616
- -int(item["score"]),
617
- item["path"],
618
- ),
619
- )
620
-
621
-
622
- def markdown_destination(token: str) -> str:
623
- token = token.strip()
624
- if token.startswith("<"):
625
- end = token.find(">", 1)
626
- return token[1:end] if end >= 0 else token
627
- chars: list[str] = []
628
- escaped = False
629
- for char in token:
630
- if escaped:
631
- chars.append(char)
632
- escaped = False
633
- continue
634
- if char == "\\":
635
- escaped = True
636
- continue
637
- if char.isspace():
638
- break
639
- chars.append(char)
640
- if escaped:
641
- chars.append("\\")
642
- return "".join(chars)
643
-
644
-
645
- def clean_reference_token(token: str) -> str | None:
646
- token = markdown_destination(token).strip().strip("<>\"'")
647
- token = token.split("#", 1)[0].split("?", 1)[0].strip()
648
- if not token or token.startswith(("http://", "https://", "mailto:", "data:", "#", "~")):
649
- return None
650
- if " " in token and not token.startswith("./") and not token.startswith("../"):
651
- return None
652
- return token
653
-
654
-
655
- def strong_unresolved_path_hint(token: str) -> bool:
656
- if not token or token.startswith(("/", "~", "$", "...")):
657
- return False
658
- if "://" in token or "::" in token or any(char in token for char in "*?<>|"):
659
- return False
660
- if token.endswith("/"):
661
- return False
662
- suffix = Path(token).suffix.lower()
663
- if not suffix or len(suffix) > 12:
664
- return False
665
- first = token.lstrip("./").split("/", 1)[0].lower()
666
- return first in {
667
- "src", "test", "tests", "docs", "doc", "spec", "specs", "external",
668
- "desktop", "acp", "lib", "packages", "package", ".pi", "schemas",
669
- } or token.startswith(("./", "../"))
670
-
671
-
672
- def reference_roots(root: Path, source_rel: str) -> list[Path]:
673
- root_resolved = root.resolve()
674
- source_parent = (root / source_rel).parent.resolve(strict=False)
675
- result: list[Path] = [source_parent]
676
- current = source_parent
677
- markers = ("package.json", "pyproject.toml", "Cargo.toml", "go.mod", "Package.swift")
678
- while True:
679
- if current != source_parent and any((current / marker).is_file() for marker in markers):
680
- result.append(current)
681
- if current == root_resolved or current.parent == current:
682
- break
683
- current = current.parent
684
- result.append(root_resolved)
685
- deduped: list[Path] = []
686
- seen: set[str] = set()
687
- for candidate in result:
688
- key = str(candidate)
689
- if key not in seen:
690
- seen.add(key)
691
- deduped.append(candidate)
692
- return deduped
693
-
694
-
695
- def expand_reference_token(token: str) -> list[str]:
696
- cleaned = clean_reference_token(token)
697
- if not cleaned:
698
- return []
699
- match = re.search(r"\{([^{}]+)\}", cleaned)
700
- if not match:
701
- return [cleaned]
702
- choices = [part.strip() for part in match.group(1).split(",") if part.strip()]
703
- if not choices:
704
- return [cleaned]
705
- return [cleaned[: match.start()] + choice + cleaned[match.end() :] for choice in choices]
706
-
707
-
708
- def explicit_reference_bases(root: Path, text: str) -> list[Path]:
709
- bases: list[Path] = []
710
- for raw in RELATIVE_BASE_RE.findall(text[:20000]):
711
- cleaned = clean_reference_token(raw)
712
- if not cleaned:
713
- continue
714
- candidate = root / cleaned
715
- try:
716
- resolved = candidate.resolve(strict=True)
717
- resolved.relative_to(root.resolve())
718
- except (OSError, ValueError):
719
- continue
720
- if resolved.is_dir():
721
- bases.append(resolved)
722
- return bases
723
-
724
-
725
- def resolve_reference(root: Path, source_rel: str, token: str, extra_bases: Iterable[Path] = ()) -> str | None:
726
- cleaned = clean_reference_token(token)
727
- if not cleaned or cleaned.startswith("..."):
728
- return None
729
- raw = Path(cleaned)
730
- candidates = [raw] if raw.is_absolute() else [base / raw for base in [*extra_bases, *reference_roots(root, source_rel)]]
731
- for candidate in candidates:
732
- try:
733
- resolved = candidate.resolve(strict=True)
734
- rel = resolved.relative_to(root.resolve()).as_posix()
735
- except (OSError, ValueError):
736
- continue
737
- if resolved.is_file():
738
- return rel
739
- return None
740
-
741
-
742
- def explicit_relations(root: Path, source_rel: str, text: str) -> dict[str, Any]:
743
- tokens: list[str] = []
744
- tokens.extend(MARKDOWN_LINK_RE.findall(text))
745
- tokens.extend(BACKTICK_RE.findall(text))
746
- tokens.extend(PLAIN_PATH_RE.findall(text))
747
- resolved: list[str] = []
748
- unresolved: list[str] = []
749
- extra_bases = explicit_reference_bases(root, text)
750
- for token in tokens:
751
- expanded = expand_reference_token(token)
752
- if not expanded:
753
- continue
754
- token_resolved = False
755
- for candidate_token in expanded:
756
- path = resolve_reference(root, source_rel, candidate_token, extra_bases)
757
- if path and path != source_rel:
758
- resolved.append(path)
759
- token_resolved = True
760
- cleaned = clean_reference_token(token)
761
- if not token_resolved and cleaned and strong_unresolved_path_hint(cleaned) and len(cleaned) <= 300:
762
- unresolved.append(cleaned)
763
- code: list[dict[str, str]] = []
764
- specs: list[dict[str, str]] = []
765
- for path in sorted(set(resolved)):
766
- target = specs if Path(path).suffix.lower() in DOC_EXTENSIONS else code
767
- item = {"path": path, "source": "explicit"}
768
- if target is specs:
769
- item["kind"] = "related"
770
- target.append(item)
771
- resolved_set = set(resolved)
772
- unresolved = [
773
- token for token in unresolved
774
- if not any(path.endswith("/" + token.lstrip("./")) or path == token.lstrip("./") for path in resolved_set)
775
- ]
776
- return {"code": code, "specs": specs, "unresolved": sorted(set(unresolved))[:50]}
777
-
778
-
779
- def inferred_relations(root: Path, values: Iterable[str], kind: str) -> list[dict[str, str]]:
780
- result: list[dict[str, str]] = []
781
- for value in values:
782
- rel = normalize_rel_path(root, value)
783
- if not (root / rel).is_file():
784
- raise ValueError(f"inferred {kind} relationship does not exist: {rel}")
785
- result.append({"path": rel, "source": "inferred"})
786
- return sorted({item["path"]: item for item in result}.values(), key=lambda item: item["path"])
787
-
788
-
789
- def inferred_spec_relations(root: Path, values: Iterable[str], relation_kind: str) -> list[dict[str, str]]:
790
- result = inferred_relations(root, values, "specs")
791
- return [{**item, "kind": relation_kind} for item in result]
792
-
793
-
794
- def merge_relations(explicit: list[dict[str, str]], inferred: list[dict[str, str]]) -> list[dict[str, str]]:
795
- merged = {(item["path"], item.get("kind", "")): item for item in inferred}
796
- for item in explicit:
797
- merged[(item["path"], item.get("kind", ""))] = item
798
- return [merged[key] for key in sorted(merged)]
799
-
800
-
801
- def relations_hash(relations: object) -> str:
802
- value = relations if isinstance(relations, dict) else {}
803
- code = sorted(
804
- item["path"]
805
- for item in value.get("code", [])
806
- if isinstance(item, dict) and isinstance(item.get("path"), str)
807
- )
808
- specs = sorted(
809
- (item["path"], str(item.get("kind", "related")))
810
- for item in value.get("specs", [])
811
- if isinstance(item, dict) and isinstance(item.get("path"), str)
812
- )
813
- payload = json.dumps({"code": code, "specs": specs}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
814
- return sha256_bytes(payload.encode("utf-8"))
815
-
816
-
817
- def current_entry_status(root: Path, entry: dict[str, Any]) -> dict[str, Any]:
818
- classification = entry.get("classification")
819
- if classification not in SPEC_CLASSIFICATIONS:
820
- return {"status": "classified", "reasons": []}
821
- rel = entry.get("path")
822
- if not isinstance(rel, str):
823
- return {"status": "unverified", "reasons": ["invalid source path"]}
824
- try:
825
- source_path = safe_project_path(root, rel)
826
- except ValueError as exc:
827
- return {"status": "unverified", "reasons": [str(exc)]}
828
- if not source_path.is_file():
829
- return {"status": "missing-source", "reasons": [rel]}
830
- current_source_hash = file_hash(source_path)
831
- indexed = entry.get("indexed")
832
- indexed_hash = indexed.get("sourceHash") if isinstance(indexed, dict) else None
833
- indexed_changed = not isinstance(indexed_hash, str) or current_source_hash != indexed_hash
834
- verified = entry.get("verified")
835
- if not isinstance(verified, dict) or not isinstance(verified.get("sourceHash"), str):
836
- if indexed_changed:
837
- return {"status": "spec-changed", "reasons": [f"source:{rel}"]}
838
- return {"status": "unverified", "reasons": ["semantic verification required"]}
839
- source_changed = current_source_hash != verified.get("sourceHash")
840
-
841
- relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
842
- expected_relations_hash = verified.get("relationsHash")
843
- relation_map_changed = (
844
- not isinstance(expected_relations_hash, str)
845
- or not SHA256_RE.fullmatch(expected_relations_hash)
846
- or expected_relations_hash != relations_hash(relations)
847
- )
848
- code_relations = relations.get("code") if isinstance(relations.get("code"), list) else []
849
- expected_hashes = verified.get("codeHashes") if isinstance(verified.get("codeHashes"), dict) else {}
850
- changed_inputs: list[str] = []
851
- for relation in code_relations:
852
- if not isinstance(relation, dict) or not isinstance(relation.get("path"), str):
853
- continue
854
- code_rel = relation["path"]
855
- expected_hash = expected_hashes.get(code_rel)
856
- if not isinstance(expected_hash, str) or not SHA256_RE.fullmatch(expected_hash):
857
- changed_inputs.append(code_rel)
858
- continue
859
- try:
860
- code_path = safe_project_path(root, code_rel)
861
- except ValueError:
862
- changed_inputs.append(code_rel)
863
- continue
864
- current_hash = file_hash(code_path)
865
- if expected_hash != current_hash:
866
- changed_inputs.append(code_rel)
867
-
868
- if relation_map_changed:
869
- changed_inputs.append("[relation-map]")
870
-
871
- if source_changed and changed_inputs:
872
- status = "spec+inputs-changed"
873
- elif source_changed:
874
- status = "spec-changed"
875
- elif changed_inputs:
876
- status = "inputs-changed"
877
- else:
878
- status = "fresh"
879
- reasons = ([f"source:{rel}"] if source_changed else []) + [
880
- "relations:changed" if path == "[relation-map]" else f"input:{path}"
881
- for path in changed_inputs
882
- ]
883
- return {"status": status, "reasons": reasons}
884
-
885
-
886
- def record_entry(args: argparse.Namespace, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
887
- rel = normalize_rel_path(root, args.path)
888
- source_path = safe_project_path(root, rel)
889
- if not source_path.is_file():
890
- raise ValueError(f"source document does not exist: {rel}")
891
- classification = args.classification
892
- if classification in SPEC_CLASSIFICATIONS and not (args.summary and args.summary.strip()):
893
- raise ValueError("--summary is required for spec/spec-like classifications")
894
- source_bytes = source_path.read_bytes()
895
- text = source_bytes.decode("utf-8", errors="replace")
896
- source_hash = sha256_bytes(source_bytes)
897
-
898
- entries = state.setdefault("entries", {})
899
- previous = entries.get(rel) if isinstance(entries.get(rel), dict) else {}
900
- if classification in SPEC_CLASSIFICATIONS:
901
- if args.type is None and previous.get("type") is None:
902
- raise ValueError("--type is required when first recording a primary spec")
903
- if args.lifecycle is None and previous.get("lifecycle") is None:
904
- raise ValueError("--lifecycle is required when first recording a primary spec")
905
- explicit = explicit_relations(root, rel, text)
906
-
907
- previous_relations = previous.get("relations") if isinstance(previous.get("relations"), dict) else {}
908
- previous_code = previous_relations.get("code") if isinstance(previous_relations.get("code"), list) else []
909
- previous_specs = previous_relations.get("specs") if isinstance(previous_relations.get("specs"), list) else []
910
- old_inferred_code = [item for item in previous_code if isinstance(item, dict) and item.get("source") == "inferred"]
911
- old_inferred_specs = [item for item in previous_specs if isinstance(item, dict) and item.get("source") == "inferred"]
912
- if args.clear_code and args.code is not None:
913
- raise ValueError("--clear-code cannot be combined with --code")
914
- if args.clear_related_specs and args.related_spec is not None:
915
- raise ValueError("--clear-related-specs cannot be combined with --related-spec")
916
- if args.clear_related_specs and (args.supersedes is not None or args.superseded_by is not None):
917
- raise ValueError("--clear-related-specs cannot be combined with --supersedes/--superseded-by")
918
- inferred_code = (
919
- []
920
- if args.clear_code
921
- else inferred_relations(root, args.code, "code")
922
- if args.code is not None
923
- else old_inferred_code
924
- )
925
- if args.clear_related_specs:
926
- inferred_specs = []
927
- elif args.related_spec is not None or args.supersedes is not None or args.superseded_by is not None:
928
- retained = [
929
- item for item in old_inferred_specs
930
- if item.get("kind") not in {
931
- "related" if args.related_spec is not None else "",
932
- "supersedes" if args.supersedes is not None else "",
933
- "superseded-by" if args.superseded_by is not None else "",
934
- }
935
- ]
936
- inferred_specs = [
937
- *retained,
938
- *inferred_spec_relations(root, args.related_spec or [], "related"),
939
- *inferred_spec_relations(root, args.supersedes or [], "supersedes"),
940
- *inferred_spec_relations(root, args.superseded_by or [], "superseded-by"),
941
- ]
942
- else:
943
- inferred_specs = old_inferred_specs
944
- relations = {
945
- "code": merge_relations(explicit["code"], inferred_code),
946
- "specs": merge_relations(explicit["specs"], inferred_specs),
947
- }
948
- if classification not in SPEC_CLASSIFICATIONS:
949
- relations = {"code": [], "specs": []}
950
-
951
- topics = sorted(set(args.topic if args.topic is not None else previous.get("topics", [])))
952
- if classification not in SPEC_CLASSIFICATIONS:
953
- topics = sorted(set(args.topic or []))
954
- lifecycle = (
955
- args.lifecycle or previous.get("lifecycle", "unknown")
956
- if classification in SPEC_CLASSIFICATIONS
957
- else "unknown"
958
- )
959
- entry = {
960
- "path": rel,
961
- "title": document_title(text, rel),
962
- "classification": classification,
963
- "type": (args.type or previous.get("type", "unknown")) if classification in SPEC_CLASSIFICATIONS else "unknown",
964
- "lifecycle": lifecycle,
965
- "confidence": args.confidence or previous.get("confidence", "unknown"),
966
- "summary": (args.summary.strip() if args.summary is not None else previous.get("summary", "")),
967
- "topics": topics,
968
- "relations": relations,
969
- "unresolvedReferences": explicit.get("unresolved", []) if classification in SPEC_CLASSIFICATIONS else [],
970
- "indexed": {
971
- "at": utc_now(),
972
- "sourceHash": source_hash,
973
- },
974
- }
975
- if classification in SPEC_CLASSIFICATIONS and isinstance(previous.get("verified"), dict):
976
- entry["verified"] = previous["verified"]
977
- if classification not in SPEC_CLASSIFICATIONS:
978
- entry["summary"] = args.summary.strip() if args.summary else ""
979
- entries[rel] = entry
980
- save_state(state_dir, state)
981
- print(f"recorded {classification}: {rel}")
982
- return 0
983
-
984
-
985
- def verify_entry(path_value: str, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
986
- rel = normalize_rel_path(root, path_value)
987
- entries = state.get("entries")
988
- if not isinstance(entries, dict) or not isinstance(entries.get(rel), dict):
989
- raise ValueError(f"state entry does not exist: {rel}")
990
- entry = entries[rel]
991
- if entry.get("classification") not in SPEC_CLASSIFICATIONS:
992
- raise ValueError(f"only primary specs can be semantically verified: {rel}")
993
- source_path = safe_project_path(root, rel)
994
- if not source_path.is_file():
995
- raise ValueError(f"source document does not exist: {rel}")
996
- source_hash = file_hash(source_path)
997
- if source_hash is None:
998
- raise ValueError(f"failed to hash source document: {rel}")
999
- relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
1000
- code_relations = relations.get("code") if isinstance(relations.get("code"), list) else []
1001
- code_hashes: dict[str, str] = {}
1002
- for item in code_relations:
1003
- if not isinstance(item, dict) or not isinstance(item.get("path"), str):
1004
- continue
1005
- relation_hash = file_hash(safe_project_path(root, item["path"]))
1006
- if relation_hash is None:
1007
- raise ValueError(f"tracked input is missing or unreadable: {item['path']}")
1008
- code_hashes[item["path"]] = relation_hash
1009
- entry["indexed"] = {"at": utc_now(), "sourceHash": source_hash}
1010
- entry["verified"] = {
1011
- "at": utc_now(),
1012
- "sourceHash": source_hash,
1013
- "codeHashes": code_hashes,
1014
- "relationsHash": relations_hash(relations),
1015
- }
1016
- entries[rel] = entry
1017
- save_state(state_dir, state)
1018
- unresolved = entry.get("unresolvedReferences") if isinstance(entry.get("unresolvedReferences"), list) else []
1019
- suffix = f" ({len(unresolved)} unresolved path hints remain)" if unresolved else ""
1020
- print(f"verified: {rel}{suffix}")
1021
- return 0
1022
-
1023
-
1024
- def relate_entry(args: argparse.Namespace, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
1025
- rel = normalize_rel_path(root, args.path)
1026
- entries = state.get("entries")
1027
- if not isinstance(entries, dict) or not isinstance(entries.get(rel), dict):
1028
- raise ValueError(f"state entry does not exist: {rel}")
1029
- entry = entries[rel]
1030
- if entry.get("classification") not in SPEC_CLASSIFICATIONS:
1031
- raise ValueError(f"only primary specs can have tracked relations: {rel}")
1032
-
1033
- relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
1034
- before = relations_hash(relations)
1035
- source_path = safe_project_path(root, rel)
1036
- if not source_path.is_file():
1037
- raise ValueError(f"source document does not exist: {rel}")
1038
- explicit = explicit_relations(root, rel, source_path.read_text(encoding="utf-8", errors="replace"))
1039
- code = [
1040
- item for item in relations.get("code", [])
1041
- if isinstance(item, dict) and item.get("source") == "inferred"
1042
- ]
1043
- specs = [
1044
- item for item in relations.get("specs", [])
1045
- if isinstance(item, dict) and item.get("source") == "inferred"
1046
- ]
1047
-
1048
- for value in args.add_code or []:
1049
- for item in inferred_relations(root, [value], "code"):
1050
- if not any(existing.get("path") == item["path"] for existing in code):
1051
- code.append(item)
1052
- for value in args.remove_code or []:
1053
- target = normalize_rel_path(root, value)
1054
- code = [
1055
- item for item in code
1056
- if not (item.get("path") == target and item.get("source") == "inferred")
1057
- ]
1058
-
1059
- spec_ops = [
1060
- (args.add_related_spec or [], "related", True),
1061
- (args.add_supersedes or [], "supersedes", True),
1062
- (args.add_superseded_by or [], "superseded-by", True),
1063
- (args.remove_related_spec or [], "related", False),
1064
- (args.remove_supersedes or [], "supersedes", False),
1065
- (args.remove_superseded_by or [], "superseded-by", False),
1066
- ]
1067
- for values, kind, adding in spec_ops:
1068
- for value in values:
1069
- target = normalize_rel_path(root, value)
1070
- if adding:
1071
- item = inferred_spec_relations(root, [value], kind)[0]
1072
- if not any(existing.get("path") == target and existing.get("kind", "related") == kind for existing in specs):
1073
- specs.append(item)
1074
- else:
1075
- specs = [
1076
- item for item in specs
1077
- if not (
1078
- item.get("path") == target
1079
- and item.get("kind", "related") == kind
1080
- and item.get("source") == "inferred"
1081
- )
1082
- ]
1083
-
1084
- code = sorted(code, key=lambda item: (str(item.get("path", "")), str(item.get("source", ""))))
1085
- specs = sorted(specs, key=lambda item: (str(item.get("path", "")), str(item.get("kind", "related")), str(item.get("source", ""))))
1086
- updated = {
1087
- "code": merge_relations(explicit["code"], code),
1088
- "specs": merge_relations(explicit["specs"], specs),
1089
- }
1090
- after = relations_hash(updated)
1091
- if before == after:
1092
- print(f"unchanged relations: {rel}")
1093
- return 0
1094
- entry["relations"] = updated
1095
- entry["unresolvedReferences"] = explicit.get("unresolved", [])
1096
- entries[rel] = entry
1097
- save_state(state_dir, state)
1098
- print(f"updated relations: {rel} — {current_entry_status(root, entry)['status']}")
1099
- return 0
1100
-
1101
-
1102
- def markdown_inline_list(values: list[str], maximum: int = 8) -> str:
1103
- shown = values[:maximum]
1104
- rendered = ", ".join(f"`{value}`" for value in shown)
1105
- if len(values) > maximum:
1106
- rendered += f", +{len(values) - maximum} more"
1107
- return rendered
1108
-
1109
-
1110
- def compact_text(value: object, maximum: int = 180) -> str:
1111
- text = re.sub(r"\s+", " ", str(value or "")).strip()
1112
- if len(text) <= maximum:
1113
- return text
1114
- return text[: maximum - 1].rstrip() + "…"
1115
-
1116
-
1117
- def lifecycle_rank(value: object) -> int:
1118
- return {
1119
- "active": 0,
1120
- "proposed": 1,
1121
- "unknown": 2,
1122
- "historical": 3,
1123
- "superseded": 4,
1124
- }.get(str(value), 5)
1125
-
1126
-
1127
- SEARCH_STOPWORDS = {
1128
- "a", "an", "and", "are", "do", "does", "for", "how", "in", "is", "of", "on", "or", "the", "to", "what", "where", "why",
1129
- "а", "в", "где", "и", "как", "ли", "на", "почему", "что",
1130
- }
1131
-
1132
- RELATION_GENERIC_TERMS = {
1133
- "agent",
1134
- "app",
1135
- "config",
1136
- "index",
1137
- "model",
1138
- "prompt",
1139
- "result",
1140
- "session",
1141
- "spec",
1142
- "state",
1143
- "test",
1144
- "tool",
1145
- }
1146
-
1147
-
1148
- def normalize_search_text(value: object) -> str:
1149
- return re.sub(r"\s+", " ", unicodedata.normalize("NFKC", str(value or "")).casefold()).strip()
1150
-
1151
-
1152
- def search_terms(value: object) -> set[str]:
1153
- normalized = normalize_search_text(value)
1154
- result: set[str] = set()
1155
- for token in re.findall(r"[\w][\w.+@:/-]*", normalized, flags=re.UNICODE):
1156
- if len(token) > 1 and token not in SEARCH_STOPWORDS:
1157
- result.add(token)
1158
- # Keep the compound for exact/prefix matching, but also expose path and
1159
- # identifier components so `session cancellation` can match
1160
- # `session-cancellation` and `model usage` can match `model-usage.ts`.
1161
- for part in re.split(r"[/_-]+", token):
1162
- if len(part) > 1 and part not in SEARCH_STOPWORDS:
1163
- result.add(part)
1164
- return result
1165
-
1166
-
1167
- def search_term_matches(query_term: str, field_term: str) -> bool:
1168
- if query_term == field_term:
1169
- return True
1170
- if len(query_term) < 4 or len(field_term) < 4:
1171
- return False
1172
- return field_term.startswith(query_term) or query_term.startswith(field_term)
1173
-
1174
-
1175
- def relation_paths(entry: dict[str, Any], kind: str, spec_kinds: set[str] | None = None) -> list[str]:
1176
- relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
1177
- values = relations.get(kind) if isinstance(relations.get(kind), list) else []
1178
- return [
1179
- str(item["path"])
1180
- for item in values
1181
- if isinstance(item, dict)
1182
- and isinstance(item.get("path"), str)
1183
- and (spec_kinds is None or str(item.get("kind", "related")) in spec_kinds)
1184
- ]
1185
-
1186
-
1187
- def relation_search_terms(paths: Iterable[str]) -> set[str]:
1188
- result: set[str] = set()
1189
- for value in paths:
1190
- name = Path(value).name
1191
- if not name:
1192
- continue
1193
- result.update(search_terms(name))
1194
- stem = Path(name).stem
1195
- if len(stem) > 1:
1196
- result.add(normalize_search_text(stem))
1197
- return result
1198
-
1199
-
1200
- def score_search_query(entry: dict[str, Any], query: str) -> tuple[float, list[str], bool, float, float]:
1201
- normalized_query = normalize_search_text(query)
1202
- if not normalized_query:
1203
- return 0.0, [], False, 0.0, 0.0
1204
- query_terms = search_terms(normalized_query)
1205
- title = str(entry.get("title", ""))
1206
- path = str(entry.get("path", ""))
1207
- summary = str(entry.get("summary", ""))
1208
- topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
1209
- code_relations = relation_paths(entry, "code")
1210
- spec_relations = relation_paths(entry, "specs", {"related"})
1211
- supersedes = relation_paths(entry, "specs", {"supersedes"})
1212
- superseded_by = relation_paths(entry, "specs", {"superseded-by"})
1213
- supersession = supersedes + superseded_by
1214
-
1215
- fields = {
1216
- "title": normalize_search_text(title),
1217
- "topics": normalize_search_text(" ".join(topics)),
1218
- "path": normalize_search_text(path),
1219
- "summary": normalize_search_text(summary),
1220
- "relations.code": normalize_search_text(" ".join(code_relations)),
1221
- "relations.specs": normalize_search_text(" ".join(spec_relations)),
1222
- "relations.supersession": normalize_search_text(" ".join(supersession)),
1223
- }
1224
- term_fields = {
1225
- "title": search_terms(fields["title"]),
1226
- "topics": search_terms(fields["topics"]),
1227
- "path": search_terms(fields["path"]),
1228
- "summary": search_terms(fields["summary"]),
1229
- # Relations are routing hints. Score only their concrete leaf names,
1230
- # not generic directory segments such as `src`, `session`, or `dcp`.
1231
- # Exact full relation paths are handled separately above.
1232
- "relations.code": relation_search_terms(code_relations),
1233
- "relations.specs": relation_search_terms(spec_relations),
1234
- "relations.supersession": relation_search_terms(supersession),
1235
- }
1236
- weights = {
1237
- "title": 8.0,
1238
- "topics": 6.0,
1239
- "relations.code": 6.0,
1240
- "relations.specs": 5.0,
1241
- "relations.supersession": 5.0,
1242
- "path": 4.0,
1243
- "summary": 2.0,
1244
- }
1245
- score = 0.0
1246
- matches: list[str] = []
1247
- exact_path_match = False
1248
- matched_query_terms: set[str] = set()
1249
- semantic_matched_terms: set[str] = set()
1250
-
1251
- if normalized_query == fields["path"]:
1252
- score += 50.0
1253
- matches.append("exact:spec-path")
1254
- exact_path_match = True
1255
- matched_query_terms.update(query_terms)
1256
- semantic_matched_terms.update(query_terms)
1257
- for relation_kind, paths, exact_weight in (
1258
- ("relations.code", code_relations, 40.0),
1259
- ("relations.specs", spec_relations, 36.0),
1260
- ("relations.supersession", supersession, 34.0),
1261
- ):
1262
- if any(normalized_query == normalize_search_text(value) for value in paths):
1263
- score += exact_weight
1264
- matches.append(f"exact:{relation_kind}")
1265
- exact_path_match = True
1266
- matched_query_terms.update(query_terms)
1267
-
1268
- phrase_weights = {
1269
- "title": 12.0,
1270
- "topics": 10.0,
1271
- "relations.code": 18.0,
1272
- "relations.specs": 14.0,
1273
- "relations.supersession": 14.0,
1274
- "path": 12.0,
1275
- "summary": 4.0,
1276
- }
1277
- if len(normalized_query) >= 4:
1278
- for name, value in fields.items():
1279
- if normalized_query and normalized_query in value:
1280
- score += phrase_weights[name]
1281
- matches.append(f"phrase:{name}")
1282
- matched_query_terms.update(query_terms)
1283
- if not name.startswith("relations."):
1284
- semantic_matched_terms.update(query_terms)
1285
-
1286
- for query_term in query_terms:
1287
- for name, field_terms in term_fields.items():
1288
- if name.startswith("relations.") and query_term in RELATION_GENERIC_TERMS:
1289
- continue
1290
- if any(search_term_matches(query_term, field_term) for field_term in field_terms):
1291
- score += weights[name]
1292
- matches.append(f"term:{query_term}:{name}")
1293
- matched_query_terms.add(query_term)
1294
- if not name.startswith("relations."):
1295
- semantic_matched_terms.add(query_term)
1296
-
1297
- coverage = len(matched_query_terms) / len(query_terms) if query_terms else (1.0 if exact_path_match else 0.0)
1298
- semantic_coverage = len(semantic_matched_terms) / len(query_terms) if query_terms else 0.0
1299
- # Prefer entries that explain the whole intent in title/topics/path/summary,
1300
- # not entries that accumulate a large score from one repeated domain word.
1301
- # Relation matches still provide strong exact component/path routing above.
1302
- score += semantic_coverage * 12.0
1303
- if semantic_coverage >= 0.999 and len(query_terms) >= 2:
1304
- score += 8.0
1305
- return score, sorted(set(matches)), exact_path_match, round(coverage, 3), round(semantic_coverage, 3)
1306
-
1307
-
1308
- def search_entries(root: Path, state: dict[str, Any], queries: list[str], limit: int, include_secondary: bool) -> list[dict[str, Any]]:
1309
- normalized_queries: list[str] = []
1310
- for query in queries:
1311
- normalized = normalize_search_text(query)
1312
- if normalized and normalized not in normalized_queries:
1313
- normalized_queries.append(normalized)
1314
- if not normalized_queries:
1315
- raise ValueError("search query must not be empty")
1316
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1317
- results: list[dict[str, Any]] = []
1318
- for entry in entries.values():
1319
- if not isinstance(entry, dict):
1320
- continue
1321
- classification = entry.get("classification")
1322
- if classification not in SPEC_CLASSIFICATIONS and not (include_secondary and classification == "design-only"):
1323
- continue
1324
- query_matches: list[dict[str, Any]] = []
1325
- for query in normalized_queries:
1326
- query_score, matches, exact_path_match, coverage, semantic_coverage = score_search_query(entry, query)
1327
- if query_score > 0:
1328
- query_matches.append(
1329
- {
1330
- "query": query,
1331
- "score": round(query_score, 2),
1332
- "matches": matches,
1333
- "exactPath": exact_path_match,
1334
- "coverage": coverage,
1335
- "semanticCoverage": semantic_coverage,
1336
- }
1337
- )
1338
- if not query_matches:
1339
- continue
1340
- query_matches.sort(key=lambda item: -float(item["score"]))
1341
- best_query_score = float(query_matches[0]["score"])
1342
- supporting_score = min(10.0, sum(float(item["score"]) for item in query_matches[1:]) * 0.15)
1343
- score = best_query_score + supporting_score
1344
- lifecycle = entry.get("lifecycle", "unknown")
1345
- score += {"active": 3.0, "proposed": 1.5, "unknown": 0.0, "historical": -1.0, "superseded": -3.0}.get(str(lifecycle), 0.0)
1346
- if classification == "design-only":
1347
- score -= 2.0
1348
- status = current_entry_status(root, entry) if classification in SPEC_CLASSIFICATIONS else {"status": "secondary", "reasons": []}
1349
- title = str(entry.get("title", ""))
1350
- path = str(entry.get("path", ""))
1351
- summary = str(entry.get("summary", ""))
1352
- topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
1353
- results.append(
1354
- {
1355
- "path": path,
1356
- "title": title,
1357
- "classification": classification,
1358
- "type": entry.get("type", "unknown"),
1359
- "lifecycle": lifecycle,
1360
- "status": status["status"],
1361
- "score": round(score, 2),
1362
- "topics": topics,
1363
- "summary": summary,
1364
- "queryMatches": query_matches,
1365
- }
1366
- )
1367
- results.sort(key=lambda item: (-float(item["score"]), lifecycle_rank(item["lifecycle"]), str(item["title"]).lower(), str(item["path"])))
1368
- return results[: max(1, limit)]
1369
-
1370
-
1371
- def search_quality(results: list[dict[str, Any]]) -> dict[str, Any]:
1372
- if not results:
1373
- return {
1374
- "confidence": "none",
1375
- "catalogFallbackRecommended": True,
1376
- "semanticRerankRecommended": False,
1377
- "reason": "no lexical/relation match",
1378
- }
1379
- top = float(results[0]["score"])
1380
- second = float(results[1]["score"]) if len(results) > 1 else None
1381
- margin = top - second if second is not None else top
1382
- exact = any(bool(item.get("exactPath")) for item in results[0].get("queryMatches", []))
1383
- top_coverage = max((float(item.get("coverage", 0.0)) for item in results[0].get("queryMatches", [])), default=0.0)
1384
- top_semantic_coverage = max((float(item.get("semanticCoverage", 0.0)) for item in results[0].get("queryMatches", [])), default=0.0)
1385
- if exact or (top >= 20.0 and top_semantic_coverage >= 0.66):
1386
- confidence = "high"
1387
- elif top >= 12.0 and top_semantic_coverage >= 0.4:
1388
- confidence = "medium"
1389
- else:
1390
- confidence = "low"
1391
- return {
1392
- "confidence": confidence,
1393
- "catalogFallbackRecommended": confidence == "low",
1394
- # Candidate generation is intentionally lexical/relation based. When
1395
- # more than one candidate survives, the current LLM should always do
1396
- # the cheap semantic choice from compact metadata instead of trusting
1397
- # the numeric rank as semantic truth.
1398
- "semanticRerankRecommended": len(results) > 1,
1399
- "topScore": round(top, 2),
1400
- "topMargin": round(margin, 2),
1401
- "topCoverage": round(top_coverage, 3),
1402
- "topSemanticCoverage": round(top_semantic_coverage, 3),
1403
- "reason": "exact relation/path match" if exact else "weighted metadata/relation match",
1404
- }
1405
-
1406
-
1407
- def render_index(root: Path, state_dir: Path, state: dict[str, Any]) -> str:
1408
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1409
- specs = [entry for entry in entries.values() if isinstance(entry, dict) and entry.get("classification") in SPEC_CLASSIFICATIONS]
1410
- specs.sort(key=lambda item: (lifecycle_rank(item.get("lifecycle")), str(item.get("title", "")).lower(), str(item.get("path", ""))))
1411
- statuses = {entry["path"]: current_entry_status(root, entry) for entry in specs if isinstance(entry.get("path"), str)}
1412
-
1413
- lines = [
1414
- "# Spec Wiki",
1415
- "",
1416
- "> Generated routing index. Primary specs remain the source of truth.",
1417
- "> Prefer `spec_wiki.py search <query>` for retrieval; `fresh` exists only after explicit semantic verification.",
1418
- "",
1419
- ]
1420
- unverified_current = [
1421
- entry for entry in specs
1422
- if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}
1423
- and statuses.get(entry["path"], {}).get("status") == "unverified"
1424
- ]
1425
- if unverified_current:
1426
- lines.extend(
1427
- [
1428
- f"> {len(unverified_current)} current/proposed primary spec(s) are indexed but not semantically verified yet.",
1429
- "",
1430
- ]
1431
- )
1432
- stale = [
1433
- (entry, statuses.get(entry["path"], {"status": "unverified", "reasons": []}))
1434
- for entry in specs
1435
- if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}
1436
- and statuses.get(entry["path"], {}).get("status") not in {"fresh", "unverified"}
1437
- ]
1438
- if stale:
1439
- lines.extend(["## Needs review", ""])
1440
- for entry, status in stale:
1441
- reason_text = "; ".join(status.get("reasons", [])[:4])
1442
- suffix = f" — {reason_text}" if reason_text else ""
1443
- lines.append(f"- `{entry['path']}` — **{status['status']}**{suffix}")
1444
- lines.append("")
1445
-
1446
- lines.extend(["## Current / proposed primary specs", ""])
1447
- if not specs:
1448
- lines.extend(["_No primary specs have been semantically classified yet._", ""])
1449
- for entry in [item for item in specs if item.get("lifecycle", "unknown") not in {"historical", "superseded"}]:
1450
- status = statuses.get(entry["path"], {"status": "unverified"})
1451
- topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
1452
- topic_text = ",".join(topics[:6]) or "-"
1453
- lines.append(
1454
- f"- **{entry.get('title') or entry['path']}** — `{entry['path']}` · "
1455
- f"{entry.get('type', 'unknown')}/{entry.get('lifecycle', 'unknown')} · "
1456
- f"**{status.get('status', 'unverified')}** · {topic_text} — {compact_text(entry.get('summary', ''), 180)}"
1457
- )
1458
- archived = [item for item in specs if item.get("lifecycle", "unknown") in {"historical", "superseded"}]
1459
- if archived:
1460
- lines.extend(["", "## Historical / superseded primary specs", ""])
1461
- for entry in archived:
1462
- lines.append(
1463
- f"- **{entry.get('title') or entry['path']}** — `{entry['path']}` · "
1464
- f"{entry.get('type', 'unknown')}/{entry.get('lifecycle', 'unknown')} — "
1465
- f"{compact_text(entry.get('summary', ''), 140)}"
1466
- )
1467
- secondary = [
1468
- entry for entry in entries.values()
1469
- if isinstance(entry, dict) and entry.get("classification") == "design-only"
1470
- ]
1471
- if secondary:
1472
- secondary.sort(key=lambda item: (str(item.get("title", "")).lower(), str(item.get("path", ""))))
1473
- lines.extend(["", "## Secondary design / decision references", ""])
1474
- for entry in secondary:
1475
- lines.append(f"- **{entry.get('title') or entry['path']}** — `{entry['path']}`")
1476
- lines.append("")
1477
- return "\n".join(lines).rstrip() + "\n"
1478
-
1479
-
1480
- def audit_payload(root: Path, state_dir: Path, config: dict[str, Any], state: dict[str, Any], candidate_limit: int) -> dict[str, Any]:
1481
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1482
- specs = [entry for entry in entries.values() if isinstance(entry, dict) and entry.get("classification") in SPEC_CLASSIFICATIONS]
1483
- statuses = []
1484
- for entry in sorted(specs, key=lambda item: str(item.get("path", ""))):
1485
- status = current_entry_status(root, entry)
1486
- statuses.append({"path": entry.get("path"), "lifecycle": entry.get("lifecycle", "unknown"), **status})
1487
- candidates = discover_candidates(root, state_dir, config, state, include_all=False)
1488
- return {
1489
- "schemaVersion": SCHEMA_VERSION,
1490
- "primarySpecCount": len(specs),
1491
- "currentPrimarySpecCount": sum(1 for entry in specs if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}),
1492
- "freshCount": sum(1 for item in statuses if item["status"] == "fresh"),
1493
- "unverifiedCount": sum(1 for item in statuses if item["status"] == "unverified"),
1494
- "needsReviewCount": sum(
1495
- 1 for item in statuses
1496
- if item["lifecycle"] not in {"historical", "superseded"}
1497
- and item["status"] not in {"fresh", "unverified"}
1498
- ),
1499
- "unresolvedReferenceCount": sum(
1500
- len(entry.get("unresolvedReferences", []))
1501
- for entry in specs if isinstance(entry.get("unresolvedReferences"), list)
1502
- ),
1503
- "statuses": statuses,
1504
- "candidateCount": len(candidates),
1505
- "candidates": candidates[:candidate_limit],
1506
- "candidateListTruncated": len(candidates) > candidate_limit,
1507
- }
1508
-
1509
-
1510
- def print_audit(payload: dict[str, Any]) -> None:
1511
- print(
1512
- f"primary specs: {payload['primarySpecCount']} ({payload['currentPrimarySpecCount']} current/proposed) | "
1513
- f"fresh: {payload['freshCount']} | unverified: {payload['unverifiedCount']} | "
1514
- f"needs review: {payload['needsReviewCount']} | unresolved refs: {payload['unresolvedReferenceCount']} | "
1515
- f"new/changed candidates: {payload['candidateCount']}"
1516
- )
1517
- for item in payload["statuses"]:
1518
- if item["status"] == "fresh" or item.get("lifecycle") in {"historical", "superseded"}:
1519
- continue
1520
- reasons = ", ".join(item.get("reasons", [])[:4])
1521
- print(f" {item['status']:24} {item['path']}{' — ' + reasons if reasons else ''}")
1522
- if payload["candidates"]:
1523
- print("candidates:")
1524
- for item in payload["candidates"]:
1525
- known = f" known={item['knownClassification']}" if item.get("knownClassification") else ""
1526
- print(f" score={item['score']:>3} {item['roleHint']:14} {item['path']}{known}")
1527
- if payload["candidateListTruncated"]:
1528
- print(" ... candidate list truncated")
1529
-
1530
-
1531
- def validate_state(root: Path, state: dict[str, Any]) -> tuple[list[str], list[str]]:
1532
- errors: list[str] = []
1533
- warnings: list[str] = []
1534
- if state.get("schemaVersion") != SCHEMA_VERSION:
1535
- errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
1536
- entries = state.get("entries")
1537
- if not isinstance(entries, dict):
1538
- return errors + ["entries must be an object"], warnings
1539
- for key, entry in entries.items():
1540
- if not isinstance(entry, dict):
1541
- errors.append(f"{key}: entry must be an object")
1542
- continue
1543
- path = entry.get("path")
1544
- if path != key:
1545
- errors.append(f"{key}: entry.path must equal state key")
1546
- try:
1547
- normalized = normalize_rel_path(root, str(path))
1548
- if normalized != path:
1549
- errors.append(f"{key}: path is not normalized")
1550
- except ValueError as exc:
1551
- errors.append(f"{key}: {exc}")
1552
- classification = entry.get("classification")
1553
- if classification not in CLASSIFICATIONS:
1554
- errors.append(f"{key}: invalid classification {classification!r}")
1555
- if classification in SPEC_CLASSIFICATIONS and not str(entry.get("summary", "")).strip():
1556
- errors.append(f"{key}: primary spec requires non-empty summary")
1557
- if entry.get("type", "unknown") not in SPEC_TYPES:
1558
- errors.append(f"{key}: invalid type {entry.get('type')!r}")
1559
- lifecycle = entry.get("lifecycle", "unknown")
1560
- if lifecycle not in LIFECYCLES:
1561
- errors.append(f"{key}: invalid lifecycle {lifecycle!r}")
1562
- if entry.get("confidence", "unknown") not in CONFIDENCES:
1563
- errors.append(f"{key}: invalid confidence {entry.get('confidence')!r}")
1564
- indexed = entry.get("indexed")
1565
- if not isinstance(indexed, dict):
1566
- errors.append(f"{key}: indexed must be an object")
1567
- else:
1568
- indexed_hash = indexed.get("sourceHash")
1569
- if not isinstance(indexed_hash, str) or not SHA256_RE.fullmatch(indexed_hash):
1570
- errors.append(f"{key}: indexed.sourceHash must be a sha256 hash")
1571
- unresolved = entry.get("unresolvedReferences", [])
1572
- if not isinstance(unresolved, list) or any(not isinstance(item, str) for item in unresolved):
1573
- errors.append(f"{key}: unresolvedReferences must be an array of strings")
1574
- relations = entry.get("relations", {})
1575
- if not isinstance(relations, dict):
1576
- errors.append(f"{key}: relations must be an object")
1577
- else:
1578
- for kind in ("code", "specs"):
1579
- values = relations.get(kind, [])
1580
- if not isinstance(values, list):
1581
- errors.append(f"{key}: relations.{kind} must be an array")
1582
- continue
1583
- for relation in values:
1584
- if not isinstance(relation, dict) or relation.get("source") not in {"explicit", "inferred"} or not isinstance(relation.get("path"), str):
1585
- errors.append(f"{key}: malformed relations.{kind} item")
1586
- continue
1587
- if kind == "specs" and relation.get("kind", "related") not in SPEC_RELATION_KINDS:
1588
- errors.append(f"{key}: invalid spec relation kind {relation.get('kind')!r}")
1589
- try:
1590
- normalized_relation = normalize_rel_path(root, relation["path"])
1591
- if normalized_relation != relation["path"]:
1592
- errors.append(f"{key}: relation path is not normalized: {relation['path']}")
1593
- except ValueError as exc:
1594
- errors.append(f"{key}: invalid relation {relation['path']}: {exc}")
1595
- verified = entry.get("verified")
1596
- if verified is not None:
1597
- if not isinstance(verified, dict):
1598
- errors.append(f"{key}: verified must be an object")
1599
- else:
1600
- source_hash = verified.get("sourceHash")
1601
- if not isinstance(source_hash, str) or not SHA256_RE.fullmatch(source_hash):
1602
- errors.append(f"{key}: verified.sourceHash must be a sha256 hash")
1603
- code_hashes = verified.get("codeHashes")
1604
- if not isinstance(code_hashes, dict):
1605
- errors.append(f"{key}: verified.codeHashes must be an object")
1606
- else:
1607
- for relation_path, value in code_hashes.items():
1608
- if not isinstance(relation_path, str) or not isinstance(value, str) or not SHA256_RE.fullmatch(value):
1609
- errors.append(f"{key}: malformed verified.codeHashes entry")
1610
- verified_relations_hash = verified.get("relationsHash")
1611
- if verified_relations_hash is not None and (
1612
- not isinstance(verified_relations_hash, str)
1613
- or not SHA256_RE.fullmatch(verified_relations_hash)
1614
- ):
1615
- errors.append(f"{key}: verified.relationsHash must be a sha256 hash when present")
1616
- if classification in SPEC_CLASSIFICATIONS:
1617
- status = current_entry_status(root, entry)
1618
- if lifecycle not in {"historical", "superseded"} and status["status"] != "fresh":
1619
- warnings.append(f"{key}: {status['status']}")
1620
- code_values = relations.get("code", []) if isinstance(relations, dict) else []
1621
- if entry.get("type") == "as-is" and lifecycle == "active" and not code_values:
1622
- warnings.append(f"{key}: active as-is spec has no tracked implementation inputs")
1623
- if unresolved:
1624
- warnings.append(f"{key}: {len(unresolved)} unresolved path reference(s)")
1625
- if lifecycle == "superseded":
1626
- spec_values = relations.get("specs", []) if isinstance(relations, dict) else []
1627
- if not any(isinstance(item, dict) and item.get("kind") == "superseded-by" for item in spec_values):
1628
- warnings.append(f"{key}: superseded lifecycle has no superseded-by relation")
1629
- return errors, warnings
1630
-
1631
-
1632
- def path_overlaps(changed: str, tracked: str) -> bool:
1633
- changed = changed.rstrip("/")
1634
- tracked = tracked.rstrip("/")
1635
- return changed == tracked or tracked.startswith(changed + "/") or changed.startswith(tracked + "/")
1636
-
1637
-
1638
- def affected_specs(root: Path, state: dict[str, Any], changed_values: list[str]) -> list[dict[str, Any]]:
1639
- changed = [normalize_rel_path(root, value) for value in changed_values]
1640
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1641
- result: list[dict[str, Any]] = []
1642
- for entry in entries.values():
1643
- if not isinstance(entry, dict) or entry.get("classification") not in SPEC_CLASSIFICATIONS:
1644
- continue
1645
- if entry.get("lifecycle", "unknown") in {"historical", "superseded"}:
1646
- continue
1647
- tracked = [str(entry.get("path", ""))]
1648
- relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
1649
- tracked.extend(
1650
- item["path"]
1651
- for item in relations.get("code", [])
1652
- if isinstance(item, dict) and isinstance(item.get("path"), str)
1653
- )
1654
- matches = sorted({path for path in changed if any(path_overlaps(path, candidate) for candidate in tracked)})
1655
- if matches:
1656
- result.append({"path": entry.get("path"), "matchedChanges": matches, **current_entry_status(root, entry)})
1657
- return sorted(result, key=lambda item: str(item.get("path", "")))
1658
-
1659
-
1660
- def changed_document_candidate(
1661
- root: Path,
1662
- state_dir: Path,
1663
- config: dict[str, Any],
1664
- state: dict[str, Any],
1665
- rel: str,
1666
- ) -> dict[str, Any] | None:
1667
- try:
1668
- rel = normalize_rel_path(root, rel)
1669
- except ValueError:
1670
- return None
1671
- if is_under_ignored_dir(rel, state_dir, root):
1672
- return None
1673
- ignored = [str(item) for item in config.get("ignoreSpecs", [])]
1674
- excludes = [str(item) for item in config.get("exclude", [])]
1675
- forced_patterns = [str(item) for item in config.get("forceSpecs", [])]
1676
- if matches_any(rel, ignored) or matches_any(rel, excludes):
1677
- return None
1678
- forced = matches_any(rel, forced_patterns)
1679
- extensions = {
1680
- str(item).lower() if str(item).startswith(".") else f".{str(item).lower()}"
1681
- for item in config.get("includeExtensions", [])
1682
- }
1683
- if not forced and extensions and Path(rel).suffix.lower() not in extensions:
1684
- return None
1685
- path = root / rel
1686
- if not path.is_file() or path.is_symlink():
1687
- return None
1688
- loaded = read_candidate(path, int(config.get("maxFileBytes", DEFAULT_CONFIG["maxFileBytes"])))
1689
- if not loaded:
1690
- return None
1691
- text, data = loaded
1692
- score, role_hint, signals = discovery_signals(rel, text, forced)
1693
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1694
- known = entries.get(rel) if isinstance(entries.get(rel), dict) else None
1695
- return {
1696
- "path": rel,
1697
- "title": document_title(text, rel),
1698
- "score": score,
1699
- "roleHint": role_hint,
1700
- "signals": signals,
1701
- "currentHash": sha256_bytes(data),
1702
- "knownClassification": known.get("classification") if known else None,
1703
- "knownLifecycle": known.get("lifecycle") if known else None,
1704
- "requiresClassification": known is None,
1705
- "requiresReclassification": bool(
1706
- known and known.get("classification") not in SPEC_CLASSIFICATIONS
1707
- ),
1708
- "changedKnownPrimary": bool(known and known.get("classification") in SPEC_CLASSIFICATIONS),
1709
- }
1710
-
1711
-
1712
- def impact_payload(
1713
- root: Path,
1714
- state_dir: Path,
1715
- config: dict[str, Any],
1716
- state: dict[str, Any],
1717
- explicit_paths: list[str] | None,
1718
- base: str,
1719
- ) -> dict[str, Any]:
1720
- if explicit_paths:
1721
- records = [{"status": "explicit", "path": normalize_rel_path(root, value)} for value in explicit_paths]
1722
- source = "explicit"
1723
- else:
1724
- records = filter_internal_change_records(git_changed_records(root, base), state_dir, root)
1725
- source = f"git:{base}"
1726
- changed_paths = changed_record_paths(records)
1727
- known_affected = affected_specs(root, state, changed_paths) if changed_paths else []
1728
-
1729
- coverage: dict[str, list[str]] = {path: [] for path in changed_paths}
1730
- for item in known_affected:
1731
- spec_path = item.get("path")
1732
- if not isinstance(spec_path, str):
1733
- continue
1734
- for path in item.get("matchedChanges", []):
1735
- if isinstance(path, str) and path in coverage:
1736
- coverage[path].append(spec_path)
1737
- coverage_rows = [
1738
- {"path": path, "knownSpecs": sorted(set(coverage.get(path, [])))}
1739
- for path in changed_paths
1740
- ]
1741
-
1742
- changed_documents: list[dict[str, Any]] = []
1743
- document_paths: set[str] = set()
1744
- for record in records:
1745
- path = record.get("path")
1746
- if not isinstance(path, str):
1747
- continue
1748
- candidate = changed_document_candidate(root, state_dir, config, state, path)
1749
- if candidate:
1750
- candidate["changeStatus"] = record.get("status")
1751
- changed_documents.append(candidate)
1752
- document_paths.add(candidate["path"])
1753
-
1754
- entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
1755
- missing_tracked_specs: list[str] = []
1756
- for path in changed_paths:
1757
- entry = entries.get(path) if isinstance(entries.get(path), dict) else None
1758
- if entry and entry.get("classification") in SPEC_CLASSIFICATIONS and not (root / path).is_file():
1759
- missing_tracked_specs.append(path)
1760
-
1761
- uncovered_paths = [
1762
- path for path in changed_paths
1763
- if not coverage.get(path)
1764
- and path not in document_paths
1765
- and not is_under_ignored_dir(path, state_dir, root)
1766
- ]
1767
- changed_docs_requiring_classification = [
1768
- item["path"]
1769
- for item in changed_documents
1770
- if item.get("requiresClassification") or item.get("requiresReclassification")
1771
- ]
1772
-
1773
- reasons: list[str] = []
1774
- if known_affected:
1775
- reasons.append("known-relationships-affected")
1776
- if uncovered_paths:
1777
- reasons.append("changed-paths-without-known-spec-relations")
1778
- if changed_docs_requiring_classification:
1779
- reasons.append("changed-or-new-documents-require-classification")
1780
- if missing_tracked_specs:
1781
- reasons.append("tracked-primary-source-missing-or-moved")
1782
- return {
1783
- "schemaVersion": SCHEMA_VERSION,
1784
- "source": source,
1785
- "changes": records,
1786
- "changedPaths": changed_paths,
1787
- "coverage": coverage_rows,
1788
- "knownAffected": known_affected,
1789
- "uncoveredPaths": uncovered_paths,
1790
- "changedDocuments": sorted(changed_documents, key=lambda item: str(item.get("path", ""))),
1791
- "missingTrackedSpecs": sorted(missing_tracked_specs),
1792
- "semanticSweepRequired": bool(
1793
- known_affected
1794
- or uncovered_paths
1795
- or changed_docs_requiring_classification
1796
- or missing_tracked_specs
1797
- ),
1798
- "reasons": reasons,
1799
- }
1800
-
1801
-
1802
- def parser() -> argparse.ArgumentParser:
1803
- main = argparse.ArgumentParser(description=__doc__)
1804
- main.add_argument("--root", help="Project root. Defaults to Git root, then cwd.")
1805
- main.add_argument("--state-dir", help="Wiki state directory. Defaults to .spec-wiki under root.")
1806
- main.add_argument("--config", help="Optional config.jsonc/json path.")
1807
- sub = main.add_subparsers(dest="command", required=True)
1808
-
1809
- discover = sub.add_parser("discover", help="Find a bounded heuristic shortlist for semantic classification.")
1810
- discover.add_argument("--limit", type=int, default=40)
1811
- discover.add_argument("--offset", type=int, default=0)
1812
- discover.add_argument("--all", action="store_true", help="Include unchanged previously classified candidates.")
1813
- discover.add_argument(
1814
- "--all-unclassified",
1815
- action="store_true",
1816
- help="Include every unclassified document-like file regardless heuristic score; use for bounded periodic coverage audits.",
1817
- )
1818
- discover.add_argument("--json", action="store_true")
1819
-
1820
- record = sub.add_parser("record", help="Record one semantic classification/index snapshot without claiming semantic verification.")
1821
- record.add_argument("--path", required=True)
1822
- record.add_argument("--classification", required=True, choices=sorted(CLASSIFICATIONS))
1823
- record.add_argument("--type", choices=sorted(SPEC_TYPES))
1824
- record.add_argument("--lifecycle", choices=sorted(LIFECYCLES))
1825
- record.add_argument("--confidence", choices=sorted(CONFIDENCES))
1826
- record.add_argument("--summary")
1827
- record.add_argument("--topic", action="append", default=None)
1828
- record.add_argument("--code", action="append", default=None, help="Inferred implementation/test relationship; repeatable.")
1829
- record.add_argument("--related-spec", action="append", default=None, help="Inferred related spec/doc relationship; repeatable.")
1830
- record.add_argument("--supersedes", action="append", default=None, help="Inferred spec/doc superseded by this entry; repeatable.")
1831
- record.add_argument("--superseded-by", action="append", default=None, help="Inferred newer spec/doc that supersedes this entry; repeatable.")
1832
- record.add_argument("--clear-code", action="store_true", help="Clear all previously inferred code relationships before recording.")
1833
- record.add_argument("--clear-related-specs", action="store_true", help="Clear all previously inferred spec/doc relationships before recording.")
1834
-
1835
- verify = sub.add_parser("verify", help="Establish a semantic-verification baseline for one already-recorded primary spec.")
1836
- verify.add_argument("--path", required=True)
1837
-
1838
- relate = sub.add_parser("relate", help="Add/remove inferred spec relationships without rewriting classification metadata.")
1839
- relate.add_argument("--path", required=True, help="Primary spec whose relation map should change.")
1840
- relate.add_argument("--add-code", action="append", default=[])
1841
- relate.add_argument("--remove-code", action="append", default=[])
1842
- relate.add_argument("--add-related-spec", action="append", default=[])
1843
- relate.add_argument("--remove-related-spec", action="append", default=[])
1844
- relate.add_argument("--add-supersedes", action="append", default=[])
1845
- relate.add_argument("--remove-supersedes", action="append", default=[])
1846
- relate.add_argument("--add-superseded-by", action="append", default=[])
1847
- relate.add_argument("--remove-superseded-by", action="append", default=[])
1848
-
1849
- remove = sub.add_parser("remove", help="Remove one Spec Wiki metadata entry without deleting its source.")
1850
- remove.add_argument("--path", required=True)
1851
-
1852
- for name in ("audit", "status"):
1853
- audit = sub.add_parser(name, help="Report primary-spec freshness and new/changed candidates.")
1854
- audit.add_argument("--json", action="store_true")
1855
- audit.add_argument("--candidate-limit", type=int, default=20)
1856
-
1857
- affected = sub.add_parser("affected", help="Find known specs related to changed repository paths.")
1858
- affected.add_argument("paths", nargs="+")
1859
- affected.add_argument("--json", action="store_true")
1860
-
1861
- changes = sub.add_parser("changes", help="List current Git working-tree changes including untracked files and renames.")
1862
- changes.add_argument("--base", default="HEAD")
1863
- changes.add_argument("--json", action="store_true")
1864
-
1865
- impact = sub.add_parser(
1866
- "impact",
1867
- help="Build a deterministic post-change maintenance report: known affected specs, uncovered paths, changed docs, moves/deletions.",
1868
- )
1869
- impact.add_argument("paths", nargs="*", help="Task-scoped changed paths. If omitted, inspect Git changes against --base.")
1870
- impact.add_argument("--base", default="HEAD")
1871
- impact.add_argument("--json", action="store_true")
1872
-
1873
- search = sub.add_parser("search", help="Search indexed spec knowledge without loading the full generated index.")
1874
- search.add_argument("query")
1875
- search.add_argument("--also", action="append", default=[], help="Additional normalized/expanded query; repeatable. Scores are merged deterministically.")
1876
- search.add_argument("--limit", type=int, default=8)
1877
- search.add_argument("--include-secondary", action="store_true", help="Include design-only secondary references below primary specs.")
1878
- search.add_argument("--json", action="store_true")
1879
-
1880
- sub.add_parser("render", help="Regenerate the compact LLM-facing index.md.")
1881
- sub.add_parser("validate", help="Validate state schema and relationship safety.")
1882
- return main
1883
-
1884
-
1885
- def main(argv: list[str] | None = None) -> int:
1886
- args = parser().parse_args(argv)
1887
- try:
1888
- root = detect_root(args.root)
1889
- if not root.is_dir():
1890
- raise ValueError(f"project root is not a directory: {root}")
1891
- state_dir = resolve_state_dir(root, args.state_dir)
1892
- config = load_config(root, state_dir, args.config)
1893
- state = load_state(state_dir)
1894
- if args.command != "validate":
1895
- errors, _warnings = validate_state(root, state)
1896
- if errors:
1897
- raise ValueError("invalid state: " + "; ".join(errors[:5]))
1898
-
1899
- if args.command == "discover":
1900
- if args.all and args.all_unclassified:
1901
- raise ValueError("--all and --all-unclassified are mutually exclusive")
1902
- candidates = discover_candidates(
1903
- root,
1904
- state_dir,
1905
- config,
1906
- state,
1907
- include_all=args.all,
1908
- all_unclassified=args.all_unclassified,
1909
- )
1910
- offset = max(0, args.offset)
1911
- limit = max(1, args.limit)
1912
- page = candidates[offset : offset + limit]
1913
- payload = {
1914
- "total": len(candidates),
1915
- "offset": offset,
1916
- "limit": limit,
1917
- "hasMore": offset + len(page) < len(candidates),
1918
- "candidates": page,
1919
- }
1920
- if args.json:
1921
- print(json.dumps(payload, indent=2, ensure_ascii=False))
1922
- else:
1923
- print(f"candidates: {len(candidates)} | showing {offset + 1 if page else 0}-{offset + len(page)}")
1924
- for item in page:
1925
- known = f" known={item['knownClassification']}" if item.get("knownClassification") else ""
1926
- print(f"score={item['score']:>3} {item['roleHint']:14} {item['path']}{known}")
1927
- print(f" title: {item['title']}")
1928
- print(f" signals: {', '.join(item['signals']) or 'none'}")
1929
- if payload["hasMore"]:
1930
- print(
1931
- f"more: use --offset {offset + len(page)} only if state stays unchanged; "
1932
- "after recording this page rerun from --offset 0"
1933
- )
1934
- return 0
1935
-
1936
- if args.command == "record":
1937
- return record_entry(args, root, state_dir, state)
1938
-
1939
- if args.command == "verify":
1940
- return verify_entry(args.path, root, state_dir, state)
1941
-
1942
- if args.command == "relate":
1943
- return relate_entry(args, root, state_dir, state)
1944
-
1945
- if args.command == "remove":
1946
- rel = normalize_rel_path(root, args.path)
1947
- entries = state.get("entries")
1948
- if not isinstance(entries, dict) or rel not in entries:
1949
- raise ValueError(f"state entry does not exist: {rel}")
1950
- del entries[rel]
1951
- save_state(state_dir, state)
1952
- print(f"removed state entry: {rel}")
1953
- return 0
1954
-
1955
- if args.command in {"audit", "status"}:
1956
- payload = audit_payload(root, state_dir, config, state, max(1, args.candidate_limit))
1957
- if args.json:
1958
- print(json.dumps(payload, indent=2, ensure_ascii=False))
1959
- else:
1960
- print_audit(payload)
1961
- return 0
1962
-
1963
- if args.command == "affected":
1964
- result = affected_specs(root, state, args.paths)
1965
- if args.json:
1966
- print(json.dumps({"affected": result}, indent=2, ensure_ascii=False))
1967
- else:
1968
- if not result:
1969
- print("no tracked spec relationships matched")
1970
- for item in result:
1971
- print(f"{item['path']} — {item['status']} — {', '.join(item['matchedChanges'])}")
1972
- return 0
1973
-
1974
- if args.command == "changes":
1975
- records = filter_internal_change_records(git_changed_records(root, args.base), state_dir, root)
1976
- payload = {"base": args.base, "changes": records, "paths": changed_record_paths(records)}
1977
- if args.json:
1978
- print(json.dumps(payload, indent=2, ensure_ascii=False))
1979
- else:
1980
- if not records:
1981
- print("no Git working-tree changes")
1982
- for record in records:
1983
- if record.get("oldPath"):
1984
- print(f"{record['status']:>2} {record['oldPath']} -> {record['path']}")
1985
- else:
1986
- print(f"{record['status']:>2} {record['path']}")
1987
- return 0
1988
-
1989
- if args.command == "impact":
1990
- payload = impact_payload(root, state_dir, config, state, args.paths or None, args.base)
1991
- if args.json:
1992
- print(json.dumps(payload, indent=2, ensure_ascii=False))
1993
- else:
1994
- print(
1995
- f"changed: {len(payload['changedPaths'])} | known affected specs: {len(payload['knownAffected'])} | "
1996
- f"uncovered paths: {len(payload['uncoveredPaths'])} | changed docs: {len(payload['changedDocuments'])} | "
1997
- f"semantic sweep: {'yes' if payload['semanticSweepRequired'] else 'no'}"
1998
- )
1999
- for item in payload["knownAffected"]:
2000
- print(f" known {item['path']} — {item['status']} — {', '.join(item['matchedChanges'])}")
2001
- for path in payload["uncoveredPaths"]:
2002
- print(f" uncovered {path}")
2003
- for item in payload["changedDocuments"]:
2004
- classification = item.get("knownClassification") or "unclassified"
2005
- print(f" doc {item['path']} — {classification} — score={item['score']} {item['roleHint']}")
2006
- for path in payload["missingTrackedSpecs"]:
2007
- print(f" missing-spec {path}")
2008
- return 0
2009
-
2010
- if args.command == "search":
2011
- queries = [args.query, *args.also]
2012
- result = search_entries(root, state, queries, args.limit, args.include_secondary)
2013
- quality = search_quality(result)
2014
- if args.json:
2015
- print(
2016
- json.dumps(
2017
- {
2018
- "query": args.query,
2019
- "queries": [query for query in queries if normalize_search_text(query)],
2020
- **quality,
2021
- "results": result,
2022
- },
2023
- indent=2,
2024
- ensure_ascii=False,
2025
- )
2026
- )
2027
- else:
2028
- print(
2029
- f"confidence={quality['confidence']} "
2030
- f"catalogFallback={'yes' if quality['catalogFallbackRecommended'] else 'no'} "
2031
- f"semanticRerank={'yes' if quality['semanticRerankRecommended'] else 'no'}"
2032
- )
2033
- if not result:
2034
- print("no indexed spec knowledge matched")
2035
- for item in result:
2036
- topics = ",".join(item.get("topics", [])[:5]) or "-"
2037
- print(
2038
- f"score={item['score']:>5} {item['lifecycle']:10} {item['status']:18} "
2039
- f"{item['path']} — {item['title']} [{topics}]"
2040
- )
2041
- print(f" {compact_text(item.get('summary', ''), 260)}")
2042
- matched_queries = [match["query"] for match in item.get("queryMatches", [])[:3]]
2043
- if matched_queries:
2044
- print(f" matched: {' | '.join(matched_queries)}")
2045
- return 0
2046
-
2047
- if args.command == "render":
2048
- rendered = render_index(root, state_dir, state)
2049
- target = index_file(state_dir)
2050
- previous = target.read_text(encoding="utf-8") if target.exists() else None
2051
- if previous != rendered:
2052
- atomic_write_text(target, rendered)
2053
- print(f"rendered {target}")
2054
- else:
2055
- print(f"unchanged {target}")
2056
- return 0
2057
-
2058
- if args.command == "validate":
2059
- errors, warnings = validate_state(root, state)
2060
- for warning in warnings:
2061
- print(f"warning: {warning}")
2062
- for error in errors:
2063
- print(f"error: {error}", file=sys.stderr)
2064
- if errors:
2065
- return 1
2066
- print(f"valid state: {len(state.get('entries', {}))} entries, {len(warnings)} warnings")
2067
- return 0
2068
-
2069
- raise ValueError(f"unknown command: {args.command}")
2070
- except (ValueError, OSError) as exc:
2071
- print(f"spec-wiki: {exc}", file=sys.stderr)
2072
- return 2
2073
-
2074
-
2075
- if __name__ == "__main__":
2076
- raise SystemExit(main())