@deftai/directive-content 0.55.2 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,2257 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/doctor.py -- canonical doctor implementation (Epic-1 #1335).
3
+
4
+ This module now owns the core doctor logic previously in run::cmd_doctor
5
+ and its helpers (parse flags, throttle via _doctor_state, install-integrity
6
+ folding, AGENTS.md freshness, Taskfile include diagnostics, structure checks,
7
+ --fix repair, --json / --session / --quiet / --full / --project-root modes).
8
+
9
+ Thin shims remain in:
10
+ * run::cmd_doctor (delegates here after sys.path insert)
11
+ * Taskfile.yml "doctor:" target (already a shim to `run doctor`)
12
+
13
+ All new/moved code follows project testing guidelines; tests updated
14
+ in tests/cli/test_cmd_doctor.py and siblings.
15
+
16
+ See also: scripts/_doctor_state.py (throttle). Install-integrity logic
17
+ previously in framework_doctor.py (retired #1336) now lives here.
18
+
19
+ Story: #1335 / #1336 (paired in agent1 worktree).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from dataclasses import dataclass, field
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+
34
+ # --- Duplicated minimal CLI / path helpers (avoid importing heavy run) ---
35
+ # These are small, stable, and let doctor.py stay self-contained.
36
+ # Rich is optional; fall back to plain prints. Mirrors run's top-level setup.
37
+
38
+ HAS_RICH = False
39
+
40
+ console = None
41
+ Panel = None
42
+ Markdown = None
43
+ try:
44
+ from rich.console import Console
45
+ from rich.markdown import Markdown as _Markdown
46
+ from rich.panel import Panel as _Panel
47
+ console = Console()
48
+ Panel = _Panel
49
+ Markdown = _Markdown
50
+ HAS_RICH = True
51
+ except Exception: # noqa: BLE001 -- rich optional
52
+ HAS_RICH = False
53
+
54
+ def print_header(text: str):
55
+ if HAS_RICH and console and Panel:
56
+ console.print(Panel(f"[bold cyan]{text}[/bold cyan]", border_style="cyan"))
57
+ else:
58
+ print(f"\n{'=' * 60}")
59
+ print(f" {text}")
60
+ print('=' * 60)
61
+
62
+ def print_section(text: str):
63
+ if HAS_RICH and console and Markdown:
64
+ console.print(Markdown(f"## {text}"))
65
+ else:
66
+ print(f"\n{'-' * 60}")
67
+ print(f" {text}")
68
+ print('-' * 60)
69
+
70
+ def print_info(msg: str):
71
+ if HAS_RICH and console:
72
+ console.print(f"[blue]ℹ[/blue] {msg}")
73
+ else:
74
+ print(f"ℹ {msg}")
75
+
76
+ def print_success(msg: str):
77
+ if HAS_RICH and console:
78
+ console.print(f"[green]✓[/green] {msg}")
79
+ else:
80
+ print(f"✓ {msg}")
81
+
82
+ def print_warn(msg: str):
83
+ if HAS_RICH and console:
84
+ console.print(f"[yellow]⚠[/yellow] {msg}")
85
+ else:
86
+ print(f"⚠ {msg}")
87
+
88
+ def print_error(msg: str):
89
+ if HAS_RICH and console:
90
+ console.print(f"[red]✗[/red] {msg}")
91
+ else:
92
+ print(f"✗ {msg}")
93
+
94
+ # Legacy aliases for the extracted code that calls info/success etc.
95
+ info = print_info
96
+ success = print_success
97
+ warn = print_warn
98
+ error = print_error
99
+
100
+ def get_script_dir() -> Path:
101
+ """Get the directory where this script is located (works for import and direct)."""
102
+ return Path(__file__).parent.absolute()
103
+
104
+ def resolve_path(path_str: str) -> Path:
105
+ """Resolve a user-supplied path string to an absolute Path.
106
+ Expands ~ and resolves relative paths against cwd.
107
+ """
108
+ if not path_str:
109
+ return Path.cwd()
110
+ p = Path(path_str).expanduser()
111
+ if not p.is_absolute():
112
+ p = (Path.cwd() / p).resolve()
113
+ return p
114
+
115
+ def _resolve_version() -> str:
116
+ """Best-effort version (duplicated for doctor self-containment)."""
117
+ try:
118
+ for cand in [
119
+ Path(__file__).parent.parent / 'VERSION',
120
+ Path(__file__).parent / 'VERSION',
121
+ Path.cwd() / '.deft-version',
122
+ ]:
123
+ if cand.exists():
124
+ return cand.read_text(encoding='utf-8').strip()
125
+ except Exception:
126
+ pass
127
+ return 'dev'
128
+
129
+ VERSION = _resolve_version()
130
+
131
+ # UV url constant (the _check_uv_available helper remains in run for other callers)
132
+ UV_INSTALL_URL = "https://docs.astral.sh/uv/"
133
+
134
+ # --- Install-integrity checks (ported from retired framework_doctor.py #1336) ---
135
+ # Symbols (EXIT_*, run_checks, main, CheckResult, DoctorResult + 4 checks + impl)
136
+ # are inserted below in small batches. Once complete, _run_install_integrity_checks
137
+ # will delegate locally (no more self-import hack or double-scripts path).
138
+ # This satisfies the Greptile P0 (missing symbols for tests + runtime NameError/AttributeError).
139
+ # --- END PORTED CHECKS HEADER ---
140
+
141
+ # --- Ported from framework_doctor.py: constants, regexes, dataclasses, low-level helpers ---
142
+ EXIT_CLEAN = 0
143
+ EXIT_DRIFT = 1
144
+ EXIT_CONFIG_ERROR = 2
145
+
146
+
147
+ # Marker contract -- mirrors run::_AGENTS_MANAGED_OPEN_RE. Kept inline so
148
+ # this script stays pure-stdlib + cross-platform without importing run
149
+ # (which has heavy import-time side effects).
150
+ _AGENTS_MANAGED_OPEN_RE = re.compile(r"<!--\s*deft:managed-section\s+v(2|3)(?:\s+([^>]*?))?\s*-->")
151
+ _AGENTS_MANAGED_CLOSE = "<!-- /deft:managed-section -->"
152
+
153
+ # The canonical install-root declaration AGENTS.md carries one of:
154
+ # "Deft is installed in <root>/."
155
+ # "Full guidelines: <root>/main.md"
156
+ # We parse both. The first match wins.
157
+ _INSTALLED_IN_RE = re.compile(r"Deft is installed in\s+(\S+?)/?\.")
158
+ _FULL_GUIDELINES_RE = re.compile(r"Full guidelines:\s+(\S+)/main\.md")
159
+
160
+ # Pattern for referenced skill paths. Matches both ``deft/skills/<name>/SKILL.md``
161
+ # (legacy) and ``.deft/core/skills/<name>/SKILL.md`` (canonical).
162
+ _SKILL_PATH_RE = re.compile(r"(?P<root>[\w./-]+?)/skills/(?P<name>[a-z][\w-]*)/SKILL\.md")
163
+
164
+ # Deprecation-redirect sentinels embedded in stub SKILL.md files (#411).
165
+ # A skill path that resolves but is a redirect stub is treated as still
166
+ # a fail -- the operator needs to act, not be told everything is fine.
167
+ #
168
+ # Important: current real skills legitimately mention the markdown
169
+ # ``deft:deprecated-redirect`` sentinel when describing migrated
170
+ # SPECIFICATION.md / PROJECT.md state. Redirect detection therefore keys on
171
+ # the stub header shape, not substring presence anywhere in a skill body.
172
+ _DEPRECATED_REDIRECT_SENTINEL = "<!-- deft:deprecated-redirect -->"
173
+ _DEPRECATED_SKILL_REDIRECT_SENTINEL = "<!-- deft:deprecated-skill-redirect -->"
174
+ _REDIRECT_STUB_HEADER_LINES = 8
175
+
176
+
177
+ @dataclass
178
+ class CheckResult:
179
+ """Outcome of a single doctor check.
180
+
181
+ ``status`` is one of:
182
+ * ``"pass"`` -- check succeeded; no action required.
183
+ * ``"fail"`` -- check failed; drift detected and operator action
184
+ is required.
185
+ * ``"skip"`` -- check was skipped because its precondition was
186
+ not met (e.g. manifest-agreement skips when neither file exists).
187
+ * ``"error"`` -- check could not run because of a config-level
188
+ problem (e.g. project root does not exist). Propagates to
189
+ exit code 2.
190
+ """
191
+
192
+ name: str
193
+ status: str
194
+ detail: str
195
+ data: dict = field(default_factory=dict)
196
+
197
+
198
+ @dataclass
199
+ class DoctorResult:
200
+ """Aggregated doctor outcome consumed by the CLI + gate hook."""
201
+
202
+ project_root: str
203
+ install_root: str | None
204
+ exit_code: int
205
+ checks: list[CheckResult]
206
+ errors: list[str] = field(default_factory=list)
207
+
208
+ def to_dict(self) -> dict:
209
+ return {
210
+ "project_root": self.project_root,
211
+ "install_root": self.install_root,
212
+ "exit_code": self.exit_code,
213
+ "checks": [
214
+ {
215
+ "name": c.name,
216
+ "status": c.status,
217
+ "detail": c.detail,
218
+ "data": c.data,
219
+ }
220
+ for c in self.checks
221
+ ],
222
+ "errors": list(self.errors),
223
+ }
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Helpers (ported)
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def _read_text_safe(path: Path) -> str | None:
232
+ """Best-effort UTF-8 read; returns None on OSError."""
233
+ if not path.is_file():
234
+ return None
235
+ try:
236
+ return path.read_text(encoding="utf-8", errors="replace")
237
+ except OSError:
238
+ return None
239
+
240
+
241
+ def _parse_install_root_from_agents_md(text: str) -> str | None:
242
+ """Return the install root AGENTS.md claims (e.g. ``.deft/core``).
243
+
244
+ Tries the ``Deft is installed in <root>/.`` form first, then falls back
245
+ to ``Full guidelines: <root>/main.md``. Returns None when neither matches.
246
+ Pure -- no I/O.
247
+ """
248
+ match = _INSTALLED_IN_RE.search(text)
249
+ if match:
250
+ return match.group(1).strip()
251
+ match = _FULL_GUIDELINES_RE.search(text)
252
+ if match:
253
+ return match.group(1).strip()
254
+ return None
255
+
256
+
257
+ def _extract_managed_section(text: str) -> str | None:
258
+ """Return the bracketed managed-section block, or None when markers are absent."""
259
+ normalised = text.replace("\r\n", "\n")
260
+ open_match = _AGENTS_MANAGED_OPEN_RE.search(normalised)
261
+ if open_match is None:
262
+ return None
263
+ open_idx = open_match.start()
264
+ close_idx = normalised.find(_AGENTS_MANAGED_CLOSE, open_match.end())
265
+ if close_idx < 0:
266
+ return None
267
+ end = close_idx + len(_AGENTS_MANAGED_CLOSE)
268
+ return normalised[open_idx:end]
269
+
270
+
271
+ _MANIFEST_LINE_RE = re.compile(r"^\s*(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?P<value>.*?)\s*$")
272
+
273
+
274
+ def _parse_manifest(text: str) -> dict:
275
+ """Minimal YAML-ish ``key: value`` parser (#1046 PR-B AC-4).
276
+
277
+ Mirrors ``run::_parse_install_manifest``. Pure -- no I/O.
278
+ """
279
+ parsed: dict = {}
280
+ for line in text.splitlines():
281
+ stripped = line.strip()
282
+ if not stripped or stripped.startswith("#"):
283
+ continue
284
+ match = _MANIFEST_LINE_RE.match(stripped)
285
+ if match is None:
286
+ continue
287
+ key = match.group("key").strip().lower()
288
+ value = match.group("value").strip().strip("'\"")
289
+ if key:
290
+ parsed[key] = value
291
+ return parsed
292
+
293
+
294
+ def _manifest_tag_to_version(manifest: dict) -> str | None:
295
+ """Derive the bare ``.deft-version`` value from a manifest dict."""
296
+ for key in ("tag", "ref"):
297
+ raw = manifest.get(key)
298
+ if not isinstance(raw, str):
299
+ continue
300
+ candidate = raw.strip().lstrip("v")
301
+ if candidate:
302
+ return candidate
303
+ return None
304
+
305
+
306
+ def _manifest_candidate_paths(
307
+ project_root: Path, install_root: str | None
308
+ ) -> list[Path]:
309
+ """Return the canonical-first VERSION-manifest probe order (#1427).
310
+
311
+ The install provenance manifest is written to divergent paths by two
312
+ install rails: the Go installer writes the documented canonical
313
+ ``<install_root>/VERSION`` (``.deft/core/VERSION`` per #1062), while the
314
+ webinstaller writes ``.deft/VERSION`` (a 5-field manifest that omits the
315
+ #1062 ``install_root`` field). The ordering below is **canonical-first**
316
+ so an existing ``.deft/core/VERSION`` always wins over a stale
317
+ ``.deft/VERSION``:
318
+
319
+ 1. ``<install_root>/VERSION`` -- the AGENTS.md / manifest-declared
320
+ install root, when known (skipped when ``install_root`` is None).
321
+ 2. ``.deft/core/VERSION`` -- the v0.27+ canonical install (#1062).
322
+ 3. ``.deft/VERSION`` -- the webinstaller-vendored location
323
+ (#1427); restores detection for that population.
324
+ 4. ``deft/VERSION`` -- the pre-v0.27 legacy install.
325
+
326
+ Duplicates are removed while preserving order so an ``install_root`` of
327
+ ``.deft/core`` does not probe the same path twice. Pure -- builds paths
328
+ only; no filesystem access.
329
+ """
330
+ raw: list[Path] = []
331
+ if install_root:
332
+ raw.append(project_root / install_root / "VERSION")
333
+ raw.append(project_root / ".deft" / "core" / "VERSION")
334
+ raw.append(project_root / ".deft" / "VERSION")
335
+ raw.append(project_root / "deft" / "VERSION")
336
+ seen: set[str] = set()
337
+ ordered: list[Path] = []
338
+ for candidate in raw:
339
+ key = str(candidate)
340
+ if key not in seen:
341
+ seen.add(key)
342
+ ordered.append(candidate)
343
+ return ordered
344
+
345
+
346
+ def _locate_manifest(project_root: Path, install_root: str | None) -> Path | None:
347
+ """Return the first existing VERSION manifest, canonical-first (#1427).
348
+
349
+ Walks :func:`_manifest_candidate_paths` in canonical-first order and
350
+ returns the first candidate that exists on disk, or ``None`` when no
351
+ manifest is present. Centralises the manifest-location contract so
352
+ ``_check_manifest_agreement``, ``_check_install_path_consistency``, and
353
+ the #1339 payload-staleness read path all agree on where a manifest may
354
+ live -- including the webinstaller's ``.deft/VERSION`` location.
355
+ """
356
+ for candidate in _manifest_candidate_paths(project_root, install_root):
357
+ if candidate.is_file():
358
+ return candidate
359
+ return None
360
+
361
+
362
+ def _is_deprecation_redirect_stub(text: str) -> bool:
363
+ """Return True when a resolved skill file is an actual redirect stub."""
364
+ lines = text.replace("\r\n", "\n").lstrip().splitlines()
365
+ sentinels = {
366
+ _DEPRECATED_REDIRECT_SENTINEL,
367
+ _DEPRECATED_SKILL_REDIRECT_SENTINEL,
368
+ }
369
+ return any(line.strip() in sentinels for line in lines[:_REDIRECT_STUB_HEADER_LINES])
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Checks (ported from framework_doctor.py)
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ def _check_quick_start_resolves(project_root: Path, install_root: str | None) -> CheckResult:
378
+ """Check #1: QUICK-START.md resolves from the install root AGENTS.md claims."""
379
+ if install_root is None:
380
+ return CheckResult(
381
+ name="quick-start-resolves",
382
+ status="skip",
383
+ detail=(
384
+ "AGENTS.md does not declare an install root; cannot check "
385
+ "QUICK-START.md resolution."
386
+ ),
387
+ )
388
+ qs_path = project_root / install_root / "QUICK-START.md"
389
+ if qs_path.is_file():
390
+ return CheckResult(
391
+ name="quick-start-resolves",
392
+ status="pass",
393
+ detail=f"Found QUICK-START.md at {qs_path}.",
394
+ data={"path": str(qs_path), "install_root": install_root},
395
+ )
396
+ return CheckResult(
397
+ name="quick-start-resolves",
398
+ status="fail",
399
+ detail=(
400
+ f"QUICK-START.md not found at {qs_path}. AGENTS.md claims the "
401
+ f"install root is {install_root!r} but the file is missing. "
402
+ "Run `.deft/core/run agents:refresh` (Unix) / "
403
+ "`.deft\\core\\run agents:refresh` (Windows) to align AGENTS.md "
404
+ "with the on-disk install root, OR run `task upgrade` to "
405
+ "re-pull the framework if the on-disk install is missing. "
406
+ "See UPGRADING.md for the canonical drift-repair walkthrough."
407
+ ),
408
+ data={
409
+ "path": str(qs_path),
410
+ "install_root": install_root,
411
+ # Dual repair-path contract: ``suggested_fix`` is the AGENTS.md
412
+ # realignment (preferred when the on-disk framework is correct);
413
+ # ``suggested_fix_alt`` re-pulls the framework when the on-disk
414
+ # install is missing entirely. Mirrors the prose's two-option
415
+ # phrasing so programmatic consumers (sync skill / CI) see the
416
+ # same dual surface as humans (SLizard P1 on PR #1067).
417
+ "suggested_fix": ".deft/core/run agents:refresh",
418
+ "suggested_fix_alt": "task upgrade",
419
+ },
420
+ )
421
+
422
+
423
+ def _check_skill_paths_resolve(project_root: Path, agents_md_text: str) -> CheckResult:
424
+ """Check #2: every <install>/skills/<name>/SKILL.md AGENTS.md references resolves."""
425
+ referenced = sorted({m.group(0) for m in _SKILL_PATH_RE.finditer(agents_md_text)})
426
+ if not referenced:
427
+ return CheckResult(
428
+ name="skill-paths-resolve",
429
+ status="skip",
430
+ detail="AGENTS.md references no skill paths to verify.",
431
+ data={"referenced": []},
432
+ )
433
+ missing: list[str] = []
434
+ redirect_stubs: list[str] = []
435
+ for rel in referenced:
436
+ candidate = project_root / rel
437
+ if not candidate.is_file():
438
+ missing.append(rel)
439
+ continue
440
+ text = _read_text_safe(candidate)
441
+ if text is not None and _is_deprecation_redirect_stub(text):
442
+ redirect_stubs.append(rel)
443
+ if not missing and not redirect_stubs:
444
+ return CheckResult(
445
+ name="skill-paths-resolve",
446
+ status="pass",
447
+ detail=f"All {len(referenced)} skill path(s) resolve.",
448
+ data={"referenced": referenced},
449
+ )
450
+ parts: list[str] = []
451
+ if missing:
452
+ parts.append(f"missing: {missing}")
453
+ if redirect_stubs:
454
+ parts.append(f"deprecation-redirect stubs: {redirect_stubs}")
455
+ return CheckResult(
456
+ name="skill-paths-resolve",
457
+ status="fail",
458
+ detail=(
459
+ f"{len(missing)} skill path(s) do not resolve; "
460
+ f"{len(redirect_stubs)} stub redirect(s). " + "; ".join(parts)
461
+ + ". Run `.deft/core/run agents:refresh` (Unix) / "
462
+ "`.deft\\core\\run agents:refresh` (Windows) to rewrite the "
463
+ "managed AGENTS.md block so skill paths match the on-disk "
464
+ "framework, OR run `task upgrade` if the on-disk skills are "
465
+ "missing entirely. See UPGRADING.md for the drift-repair walkthrough."
466
+ ),
467
+ data={
468
+ "referenced": referenced,
469
+ "missing": missing,
470
+ "redirect_stubs": redirect_stubs,
471
+ # Dual repair-path contract -- see ``_check_quick_start_resolves``
472
+ # for the rationale (SLizard P1 on PR #1067).
473
+ "suggested_fix": ".deft/core/run agents:refresh",
474
+ "suggested_fix_alt": "task upgrade",
475
+ },
476
+ )
477
+
478
+
479
+ def _check_manifest_agreement(project_root: Path, install_root: str | None) -> CheckResult:
480
+ """Check #3: <install>/VERSION YAML manifest agrees with <root>/.deft-version.
481
+
482
+ The manifest is located via :func:`_locate_manifest` (#1427) so a
483
+ webinstaller-vendored install whose manifest is at ``.deft/VERSION`` is
484
+ found, canonical-first. ``install_root`` may be None (the webinstaller
485
+ population whose manifest omits the #1062 ``install_root`` field and
486
+ whose AGENTS.md therefore yields no install-root claim) -- the helper
487
+ still probes the canonical/legacy locations, so detection no longer
488
+ depends on the AGENTS.md install-root parse.
489
+
490
+ #1325: before the canonical-vs-bare reconciliation, detect when BOTH the
491
+ canonical ``.deft/core/VERSION`` and the legacy parent-level
492
+ ``.deft/VERSION`` exist AND disagree. Two install manifests that name
493
+ different versions is a stale source-of-truth hazard -- ``task upgrade``
494
+ migrates the legacy file (backing it up as ``.deft/VERSION.premigrate``).
495
+ """
496
+ core_manifest = project_root / ".deft" / "core" / "VERSION"
497
+ legacy_manifest = project_root / ".deft" / "VERSION"
498
+ core_dual_text = _read_text_safe(core_manifest)
499
+ legacy_dual_text = _read_text_safe(legacy_manifest)
500
+ if core_dual_text is not None and legacy_dual_text is not None:
501
+ core_ver = _manifest_tag_to_version(_parse_manifest(core_dual_text))
502
+ legacy_ver = _manifest_tag_to_version(_parse_manifest(legacy_dual_text))
503
+ if core_ver != legacy_ver:
504
+ return CheckResult(
505
+ name="manifest-agreement",
506
+ status="fail",
507
+ detail=(
508
+ f"Two install manifests disagree: .deft/core/VERSION "
509
+ f"(tag={core_ver!r}) vs legacy .deft/VERSION "
510
+ f"(tag={legacy_ver!r}). The canonical manifest is "
511
+ ".deft/core/VERSION -- run `task upgrade` to migrate the "
512
+ "stale .deft/VERSION (backed up as .deft/VERSION.premigrate). "
513
+ "See UPGRADING.md for the canonical drift-repair walkthrough."
514
+ ),
515
+ data={
516
+ "dual_manifest_drift": True,
517
+ "core_manifest_path": str(core_manifest),
518
+ "legacy_manifest_path": str(legacy_manifest),
519
+ "core_version": core_ver,
520
+ "legacy_version": legacy_ver,
521
+ "authoritative": "manifest",
522
+ "suggested_fix": "task upgrade",
523
+ },
524
+ )
525
+ manifest_path = _locate_manifest(project_root, install_root)
526
+ # Canonical-first expected location for diagnostics when no manifest is
527
+ # found on disk (``_manifest_candidate_paths`` always returns >= 1 entry).
528
+ expected_manifest_path = (
529
+ manifest_path
530
+ if manifest_path is not None
531
+ else _manifest_candidate_paths(project_root, install_root)[0]
532
+ )
533
+ bare_candidates = [
534
+ project_root / "vbrief" / ".deft-version",
535
+ project_root / ".deft-version",
536
+ ]
537
+ bare_path: Path | None = next((p for p in bare_candidates if p.is_file()), None)
538
+ manifest_text = _read_text_safe(manifest_path) if manifest_path else None
539
+ bare_text = _read_text_safe(bare_path) if bare_path else None
540
+ if manifest_text is None and bare_text is None:
541
+ return CheckResult(
542
+ name="manifest-agreement",
543
+ status="skip",
544
+ detail=(
545
+ "Neither YAML manifest nor bare .deft-version exists; "
546
+ "nothing to reconcile (greenfield install)."
547
+ ),
548
+ data={
549
+ "manifest_path": str(manifest_path) if manifest_path else None,
550
+ "bare_path": str(bare_path) if bare_path else None,
551
+ },
552
+ )
553
+ if manifest_text is None:
554
+ return CheckResult(
555
+ name="manifest-agreement",
556
+ status="fail",
557
+ detail=(
558
+ f"Bare .deft-version exists at {bare_path} but YAML manifest "
559
+ f"is missing at {expected_manifest_path}. Run `task upgrade` to write "
560
+ "the canonical manifest (#1046 PR-B AC-4). See UPGRADING.md "
561
+ "for the v0.27.x -> v0.28 transition walkthrough."
562
+ ),
563
+ data={
564
+ "manifest_path": str(manifest_path) if manifest_path else None,
565
+ "expected_manifest_path": str(expected_manifest_path),
566
+ "bare_path": str(bare_path) if bare_path else None,
567
+ "bare_value": (bare_text or "").strip() if bare_text else None,
568
+ "suggested_fix": "task upgrade",
569
+ },
570
+ )
571
+ if bare_text is None:
572
+ # YAML present, bare missing -- not a drift in itself; cmd_upgrade
573
+ # will derive the bare file on next run. Report as pass with a note.
574
+ manifest = _parse_manifest(manifest_text)
575
+ derived = _manifest_tag_to_version(manifest)
576
+ return CheckResult(
577
+ name="manifest-agreement",
578
+ status="pass",
579
+ detail=(
580
+ f"YAML manifest at {manifest_path} present; bare .deft-version "
581
+ f"absent (derived value: {derived!r} from manifest tag). "
582
+ "Run `task upgrade` to regenerate the derivative."
583
+ ),
584
+ data={
585
+ "manifest_path": str(manifest_path),
586
+ "manifest": manifest,
587
+ "derived_version": derived,
588
+ },
589
+ )
590
+ manifest = _parse_manifest(manifest_text)
591
+ derived = _manifest_tag_to_version(manifest)
592
+ bare_value = bare_text.strip()
593
+ if derived is None:
594
+ return CheckResult(
595
+ name="manifest-agreement",
596
+ status="fail",
597
+ detail=(
598
+ f"YAML manifest at {manifest_path} has no parseable tag/ref "
599
+ "field; cannot reconcile with bare .deft-version."
600
+ ),
601
+ data={
602
+ "manifest_path": str(manifest_path),
603
+ "bare_path": str(bare_path),
604
+ "manifest": manifest,
605
+ "bare_value": bare_value,
606
+ },
607
+ )
608
+ if derived == bare_value:
609
+ return CheckResult(
610
+ name="manifest-agreement",
611
+ status="pass",
612
+ detail=(
613
+ f"YAML manifest (tag={derived!r}) agrees with bare .deft-version ({bare_value!r})."
614
+ ),
615
+ data={
616
+ "manifest_path": str(manifest_path),
617
+ "bare_path": str(bare_path),
618
+ "derived_version": derived,
619
+ "bare_value": bare_value,
620
+ },
621
+ )
622
+ return CheckResult(
623
+ name="manifest-agreement",
624
+ status="fail",
625
+ detail=(
626
+ f"Drift detected: YAML manifest tag={derived!r} does NOT agree "
627
+ f"with bare .deft-version={bare_value!r}. Per #1046 PR-B AC-4 "
628
+ "the YAML manifest is the canonical source -- run `task upgrade` "
629
+ "to regenerate the bare derivative from the manifest, OR "
630
+ f"manually update {manifest_path} if the bare value is correct. "
631
+ "See UPGRADING.md for the canonical drift-repair walkthrough."
632
+ ),
633
+ data={
634
+ "manifest_path": str(manifest_path),
635
+ "bare_path": str(bare_path),
636
+ "derived_version": derived,
637
+ "bare_value": bare_value,
638
+ "authoritative": "manifest",
639
+ "suggested_fix": "task upgrade",
640
+ },
641
+ )
642
+
643
+
644
+ def _check_install_path_consistency(project_root: Path, install_root: str | None) -> CheckResult:
645
+ """Check #4: AGENTS.md install-root claim resolves to an on-disk directory.
646
+
647
+ Narrow scope by design (#1046 PR-B Greptile review #1057): this check
648
+ only verifies that the install root AGENTS.md declares is a real
649
+ directory on disk. The cross-check that the YAML manifest is
650
+ **co-located** at that root is the responsibility of check #3
651
+ (``manifest-agreement``) -- when the manifest lives at a different
652
+ install root (e.g. legacy ``deft/VERSION`` while AGENTS.md claims
653
+ ``.deft/core``), check #3 reports the drift with the manifest as the
654
+ authoritative source. Splitting the responsibility keeps each check
655
+ independently actionable: this one says "reinstall or fix AGENTS.md",
656
+ check #3 says "reconcile the manifest with the bare derivative".
657
+ """
658
+ effective_install_root = install_root
659
+ fallback_info_note = ""
660
+ source = "AGENTS.md"
661
+ # #1062: prefer the manifest-side ``install_root`` field when present --
662
+ # it is the single source of truth for the install-layout contract.
663
+ # Fall back to the legacy AGENTS.md parse only when the manifest exists
664
+ # but predates the field (legacy v0.28 shape) or no manifest exists.
665
+ # The ``source`` flag stays sticky across the manifest-found-but-empty
666
+ # path so the diagnostic prose later accurately names where the
667
+ # effective install root came from (Greptile P1 on PR #1063 -- prior
668
+ # heuristic compared values, which mislabelled when manifest and
669
+ # AGENTS.md happened to agree).
670
+ # #1427: probe the manifest canonical-first via the shared candidate
671
+ # list so a webinstaller-vendored ``.deft/VERSION`` is considered too
672
+ # (the prior shape probed only ``.deft/core/VERSION`` and legacy
673
+ # ``deft/VERSION``). Iterate the candidate list rather than call
674
+ # ``_locate_manifest`` so an existing-but-unreadable manifest (OSError /
675
+ # permission denial -> ``_read_text_safe`` returns None) falls through
676
+ # to the next candidate, preserving the ``continue``-on-unreadable
677
+ # resilience of the original two-path loop (Greptile P2 on PR #1431).
678
+ # The first READABLE manifest wins, matching the prior
679
+ # break-on-first-found semantics.
680
+ for manifest_path in _manifest_candidate_paths(project_root, install_root):
681
+ manifest_text = _read_text_safe(manifest_path)
682
+ if manifest_text is None:
683
+ continue
684
+ manifest = _parse_manifest(manifest_text)
685
+ manifest_install_root = manifest.get("install_root")
686
+ if isinstance(manifest_install_root, str) and manifest_install_root.strip():
687
+ effective_install_root = manifest_install_root.strip()
688
+ fallback_info_note = ""
689
+ source = "manifest"
690
+ break
691
+ # Manifest found but missing the #1062 ``install_root`` field
692
+ # (legacy v0.28 shape, or a webinstaller ``.deft/VERSION`` that
693
+ # omits it). Fall back to the AGENTS.md parse and note it.
694
+ # ``source`` stays "AGENTS.md" -- the manifest was found but did not
695
+ # carry the install_root field, so the effective value still came
696
+ # from the AGENTS.md parse.
697
+ fallback_info_note = (
698
+ f" INFO: manifest at {manifest_path} is missing install_root; "
699
+ "fell back to the legacy AGENTS.md install-root parse."
700
+ )
701
+ break
702
+ if effective_install_root is None:
703
+ return CheckResult(
704
+ name="install-path-consistency",
705
+ status="skip",
706
+ detail=(
707
+ "AGENTS.md does not declare an install root."
708
+ + fallback_info_note
709
+ ),
710
+ data={
711
+ "claimed_install_root": install_root,
712
+ "effective_install_root": effective_install_root,
713
+ "fallback_info_note": fallback_info_note or None,
714
+ },
715
+ )
716
+ claimed_dir = project_root / effective_install_root
717
+ if not claimed_dir.is_dir():
718
+ return CheckResult(
719
+ name="install-path-consistency",
720
+ status="fail",
721
+ detail=(
722
+ f"Install root is recorded as {effective_install_root!r} "
723
+ f"(source: {source}) but {claimed_dir} is not a directory. "
724
+ "Pick one of two repair paths: "
725
+ "(a) run `.deft/core/run agents:refresh` (Unix) / "
726
+ "`.deft\\core\\run agents:refresh` (Windows) to rewrite "
727
+ "AGENTS.md to match the on-disk framework -- pick this if "
728
+ "the framework on disk is correct; OR "
729
+ "(b) run `task relocate:relocate -- --confirm` to move the "
730
+ "framework to the path AGENTS.md / the manifest claims -- "
731
+ "pick this if AGENTS.md is correct. The YAML manifest (if "
732
+ "present) is authoritative for the install-layout contract. "
733
+ "See UPGRADING.md for the canonical drift-repair walkthrough."
734
+ ),
735
+ data={
736
+ "claimed_install_root": install_root,
737
+ "effective_install_root": effective_install_root,
738
+ "effective_install_root_source": source,
739
+ "claimed_dir": str(claimed_dir),
740
+ "claimed_dir_exists": False,
741
+ "fallback_info_note": fallback_info_note or None,
742
+ "suggested_fix": ".deft/core/run agents:refresh",
743
+ "suggested_fix_alt": "task relocate:relocate -- --confirm",
744
+ },
745
+ )
746
+ # Note: this check intentionally does NOT verify the YAML manifest
747
+ # is co-located at ``<claimed_dir>/VERSION`` -- that cross-check is
748
+ # owned by check #3 (``manifest-agreement``). See docstring for the
749
+ # rationale and the per-check responsibility split.
750
+ return CheckResult(
751
+ name="install-path-consistency",
752
+ status="pass",
753
+ detail=(
754
+ f"Install root ({effective_install_root!r}, source: {source}) "
755
+ f"matches an existing directory at {claimed_dir}."
756
+ + fallback_info_note
757
+ ),
758
+ data={
759
+ "claimed_install_root": install_root,
760
+ "effective_install_root": effective_install_root,
761
+ "effective_install_root_source": source,
762
+ "claimed_dir": str(claimed_dir),
763
+ "fallback_info_note": fallback_info_note or None,
764
+ },
765
+ )
766
+
767
+
768
+ # ---------------------------------------------------------------------------
769
+ # Top-level driver (ported) -- provides run_checks for tests + internal use
770
+ # ---------------------------------------------------------------------------
771
+
772
+
773
+ def run_checks(project_root: Path) -> dict:
774
+ """Run all four checks and return a structured payload.
775
+
776
+ Public API consumed by ``run::_maybe_run_framework_doctor`` (and tests).
777
+ Returns the DoctorResult dict shape directly. Best-effort -- any
778
+ individual check that fails to run converts to an ``error`` status and
779
+ propagates to exit code 2.
780
+ """
781
+ return _run_checks_impl(project_root).to_dict()
782
+
783
+
784
+ def _run_checks_impl(project_root: Path) -> DoctorResult:
785
+ """Internal driver -- returns the dataclass form for richer testing."""
786
+ errors: list[str] = []
787
+ if not project_root.is_dir():
788
+ return DoctorResult(
789
+ project_root=str(project_root),
790
+ install_root=None,
791
+ exit_code=EXIT_CONFIG_ERROR,
792
+ checks=[],
793
+ errors=[f"project root does not exist: {project_root}"],
794
+ )
795
+
796
+ agents_md_path = project_root / "AGENTS.md"
797
+ agents_md_text = _read_text_safe(agents_md_path)
798
+ install_root: str | None = None
799
+ if agents_md_text is not None:
800
+ install_root = _parse_install_root_from_agents_md(agents_md_text)
801
+
802
+ checks: list[CheckResult] = []
803
+
804
+ # If AGENTS.md is missing entirely, the install-root-dependent checks
805
+ # all skip; surface this fact in a synthetic check so operators see
806
+ # the cause.
807
+ if agents_md_text is None:
808
+ checks.append(
809
+ CheckResult(
810
+ name="agents-md-present",
811
+ status="fail",
812
+ detail=(
813
+ "AGENTS.md not found at project root -- run "
814
+ "`.deft/core/run agents:refresh` to generate it from "
815
+ "the canonical template."
816
+ ),
817
+ data={"agents_md_path": str(agents_md_path)},
818
+ )
819
+ )
820
+ # Still attempt the manifest agreement check (it can run without
821
+ # AGENTS.md for the greenfield case).
822
+ checks.append(_check_manifest_agreement(project_root, None))
823
+ return DoctorResult(
824
+ project_root=str(project_root),
825
+ install_root=None,
826
+ exit_code=_derive_exit_code(checks, errors),
827
+ checks=checks,
828
+ errors=errors,
829
+ )
830
+
831
+ checks.append(_check_quick_start_resolves(project_root, install_root))
832
+ checks.append(_check_skill_paths_resolve(project_root, agents_md_text))
833
+ checks.append(_check_manifest_agreement(project_root, install_root))
834
+ checks.append(_check_install_path_consistency(project_root, install_root))
835
+
836
+ return DoctorResult(
837
+ project_root=str(project_root),
838
+ install_root=install_root,
839
+ exit_code=_derive_exit_code(checks, errors),
840
+ checks=checks,
841
+ errors=errors,
842
+ )
843
+
844
+
845
+ def _derive_exit_code(checks: list[CheckResult], errors: list[str]) -> int:
846
+ """Three-state exit code from check results + errors."""
847
+ if errors or any(c.status == "error" for c in checks):
848
+ return EXIT_CONFIG_ERROR
849
+ if any(c.status == "fail" for c in checks):
850
+ return EXIT_DRIFT
851
+ return EXIT_CLEAN
852
+
853
+
854
+ # --- Extracted doctor logic (from run, markers removed, now owned here) ---
855
+ # (start of logic extracted from monolithic run per #1335)
856
+ # The block from this marker through DOCTOR-EXTRACTION-END (the end of
857
+ # cmd_doctor, just before def cmd_update) is extracted verbatim into
858
+ # scripts/doctor.py . After extraction, this region is replaced by a
859
+ # thin shim that does the path-insert + import + delegation.
860
+ # The scripts/doctor.py now owns the core doctor logic.
861
+ # ===
862
+
863
+ # ── #1272 root Taskfile.yml include diagnostics ──────────────────────────
864
+ #
865
+ # A freshly installed directive project does not have a working `task X`
866
+ # surface from the project root until the consumer wires their
867
+ # root-level Taskfile.yml to include `.deft/core/Taskfile.yml`. The
868
+ # install policy in `main.md` correctly prohibits silent mutation of
869
+ # the consumer's existing Taskfile.yml, but the framework should still
870
+ # *diagnose* the missing-include / missing-file shapes the moment the
871
+ # operator runs doctor. Interactive `run doctor --fix` may offer to
872
+ # create a Taskfile.yml when one is absent (explicit consent required);
873
+ # the default and `--session` paths NEVER mutate filesystem state.
874
+ #
875
+ # The canonical snippet is mirrored verbatim from `.deft/core/main.md`
876
+ # ("Publishing deft tasks in your project root") so doctor's output and
877
+ # the prose documentation never drift.
878
+
879
+ # Canonical YAML snippet emitted by doctor's diagnostic output and
880
+ # written verbatim when the operator opts in to interactive repair.
881
+ # Kept as a module-level constant so tests can compare against the
882
+ # exact bytes a write would produce.
883
+ _TASKFILE_INCLUDE_SNIPPET = (
884
+ "version: '3'\n"
885
+ "\n"
886
+ "includes:\n"
887
+ " deft:\n"
888
+ " taskfile: ./.deft/core/Taskfile.yml\n"
889
+ " optional: true\n"
890
+ )
891
+
892
+ # Matches a top-level YAML ``includes:`` declaration. Used by the
893
+ # indentation-aware state machine in :func:`_includes_block_has_deft_taskfile`
894
+ # to anchor the scan: a ``taskfile:`` line that lives inside any other
895
+ # block (e.g. ``vars:``, ``tasks:`` cmds, a YAML comment, a long string
896
+ # scalar) MUST NOT count as a valid deft framework include, otherwise
897
+ # the diagnostic mis-reports ``ok`` on a Taskfile that mentions the
898
+ # string ``taskfile: ./.deft/core/Taskfile.yml`` in unrelated context
899
+ # (a comment, an example block, an echo cmd). See #1303 review.
900
+ _TASKFILE_INCLUDES_KEY_RE = re.compile(
901
+ r"^(?P<indent>[\t ]*)includes\s*:\s*(?:#.*)?$",
902
+ re.IGNORECASE,
903
+ )
904
+
905
+ # Matches ``taskfile: <path-to-deft-framework-Taskfile>`` value lines that
906
+ # appear under the ``includes:`` mapping. Tolerates leading ``./``,
907
+ # surrounding whitespace, optional single/double quotes around the value,
908
+ # and an inline ``# ...`` comment trailing the value. Case-insensitive so
909
+ # both ``Taskfile.yml`` and ``taskfile.yml`` match. Indent MUST be > 0
910
+ # under a top-level ``includes:`` block.
911
+ _TASKFILE_INCLUDE_VALUE_RE = re.compile(
912
+ r"^[\t ]+taskfile\s*:\s*[\"']?\.?/?(?:\.deft/core|deft)/Taskfile\.ya?ml[\"']?"
913
+ r"\s*(?:#.*)?$",
914
+ re.IGNORECASE,
915
+ )
916
+
917
+
918
+ def _includes_block_has_deft_taskfile(text: str) -> bool:
919
+ """Return True iff a top-level ``includes:`` mapping points at deft.
920
+
921
+ Walks ``text`` line-by-line with a small indentation-aware state
922
+ machine: anchors on a top-level (indent 0) ``includes:`` key, then
923
+ scans the strictly-greater-indent body for a ``taskfile:`` property
924
+ whose value resolves to either the canonical ``./.deft/core/Taskfile.yml``
925
+ or the pre-v0.27 legacy ``./deft/Taskfile.yml``. Lines whose indent
926
+ is less-than-or-equal-to the ``includes:`` indent end the block.
927
+
928
+ Stdlib-only: ``run`` is the bootstrap entry point and cannot assume
929
+ PyYAML is installed. A full YAML walk would be more robust but adds
930
+ a runtime dependency we deliberately avoid here.
931
+ """
932
+ includes_indent: int | None = None
933
+ in_includes = False
934
+ for raw_line in text.splitlines():
935
+ stripped = raw_line.strip()
936
+ if not stripped or stripped.startswith("#"):
937
+ continue
938
+ indent = len(raw_line) - len(raw_line.lstrip(" \t"))
939
+ if not in_includes:
940
+ match = _TASKFILE_INCLUDES_KEY_RE.match(raw_line)
941
+ if match is not None and indent == 0:
942
+ includes_indent = indent
943
+ in_includes = True
944
+ continue
945
+ if indent <= (includes_indent or 0):
946
+ in_includes = False
947
+ match = _TASKFILE_INCLUDES_KEY_RE.match(raw_line)
948
+ if match is not None and indent == 0:
949
+ includes_indent = indent
950
+ in_includes = True
951
+ continue
952
+ if _TASKFILE_INCLUDE_VALUE_RE.match(raw_line):
953
+ return True
954
+ return False
955
+
956
+
957
+ def _resolve_consumer_taskfile(
958
+ project_root: Path | None = None,
959
+ ) -> Path | None:
960
+ """Return the consumer project's root Taskfile path, or None if absent.
961
+
962
+ Recognises both ``Taskfile.yml`` and ``Taskfile.yaml`` so the
963
+ diagnostic accepts whichever spelling the consumer chose. Returns
964
+ the first candidate that exists on disk; returns ``None`` when
965
+ neither file is present so callers can distinguish the
966
+ missing-file case from the missing-include case.
967
+
968
+ ``project_root`` defaults to ``Path.cwd()`` when omitted so existing
969
+ callers stay backward-compatible; the explicit-argument shape is the
970
+ canonical form so :func:`cmd_doctor` can honour a user-supplied
971
+ ``--project-root <path>`` (#1303 review).
972
+ """
973
+ if project_root is None:
974
+ project_root = Path.cwd()
975
+ for name in ("Taskfile.yml", "Taskfile.yaml"):
976
+ candidate = project_root / name
977
+ if candidate.is_file():
978
+ return candidate
979
+ return None
980
+
981
+
982
+ def _classify_taskfile_include(project_root: Path) -> str:
983
+ """Classify the consumer's root Taskfile include health (#1272).
984
+
985
+ Returns one of:
986
+ ``ok`` -- root Taskfile.yml present and includes the
987
+ deft framework Taskfile (``./.deft/core/Taskfile.yml``
988
+ or the legacy ``./deft/Taskfile.yml``).
989
+ ``missing-file`` -- neither ``Taskfile.yml`` nor ``Taskfile.yaml``
990
+ exists at the project root. Interactive
991
+ ``run doctor --fix`` may create one with
992
+ explicit consent.
993
+ ``missing-include`` -- a root Taskfile exists but contains no
994
+ include pointing at the deft framework
995
+ Taskfile. Doctor NEVER mutates an
996
+ existing user-owned Taskfile -- diagnose
997
+ only; the operator pastes the snippet.
998
+ ``unreadable`` -- a root Taskfile exists but could not be
999
+ read (permission error, etc.). Diagnose;
1000
+ do not repair.
1001
+
1002
+ Pure -- read-only filesystem probe + indentation-aware string walk.
1003
+ Never mutates state.
1004
+ """
1005
+ taskfile = _resolve_consumer_taskfile(project_root)
1006
+ if taskfile is None:
1007
+ return "missing-file"
1008
+ try:
1009
+ # ``utf-8-sig`` transparently strips a leading UTF-8 BOM if present.
1010
+ # Windows editors (Notepad, some VS Code configurations) persist YAML
1011
+ # with a BOM byte at the head; ``utf-8`` would keep the ``\ufeff``
1012
+ # prefix in ``text`` and defeat the ``^[\t ]*includes`` anchor in
1013
+ # :func:`_includes_block_has_deft_taskfile`, producing a spurious
1014
+ # ``missing-include`` diagnostic on a legitimately wired Taskfile.
1015
+ # See #1303 pass-2 review.
1016
+ text = taskfile.read_text(encoding="utf-8-sig", errors="replace")
1017
+ except OSError:
1018
+ return "unreadable"
1019
+ if _includes_block_has_deft_taskfile(text):
1020
+ return "ok"
1021
+ return "missing-include"
1022
+
1023
+
1024
+ def _format_missing_include_snippet() -> str:
1025
+ """Return the paste-ready `includes:` fragment for an existing Taskfile.
1026
+
1027
+ Used by doctor's ``missing-include`` diagnostic so the operator
1028
+ sees the exact YAML they need to paste under their existing
1029
+ ``includes:`` block, without the ``version: '3'`` header (which
1030
+ their existing file already supplies).
1031
+ """
1032
+ return (
1033
+ " deft:\n"
1034
+ " taskfile: ./.deft/core/Taskfile.yml\n"
1035
+ " optional: true\n"
1036
+ )
1037
+
1038
+
1039
+ def _parse_doctor_flags(args: list[str]) -> dict:
1040
+ """Parse the doctor-specific CLI flags (#1272, #1303 review).
1041
+
1042
+ Recognises (whitelist; unknown tokens surface as ``unknown``):
1043
+ ``--session`` -- diagnose-only, session-safe mode.
1044
+ NEVER prompts, NEVER mutates
1045
+ filesystem state. Suitable for
1046
+ invocation from session-start
1047
+ rituals.
1048
+ ``--fix`` / ``--repair`` / -- offer interactive repair when
1049
+ ``--repair-taskfile`` actionable (currently: create
1050
+ missing root Taskfile.yml with
1051
+ the canonical include). Requires
1052
+ an interactive TTY AND explicit
1053
+ operator approval at the prompt;
1054
+ ignored when ``--session`` is
1055
+ also passed.
1056
+ ``--json`` -- emit a single JSON object on
1057
+ stdout describing the findings;
1058
+ suppresses the human-readable
1059
+ prose surface. Exit code is
1060
+ still 0 (clean) / 1 (errors).
1061
+ ``--quiet`` -- suppress the per-check success
1062
+ lines; errors and warnings still
1063
+ surface.
1064
+ ``--project-root <path>`` / -- override the project root used
1065
+ ``--project-root=<path>`` for the Taskfile diagnostic.
1066
+ Defaults to :func:`Path.cwd`.
1067
+ ``-h`` / ``--help`` -- accepted (caller decides how to
1068
+ render help text); does not run
1069
+ the diagnostics.
1070
+
1071
+ Unknown tokens are collected into ``flags["unknown"]`` so the caller
1072
+ can exit non-zero with a useful error message rather than silently
1073
+ swallowing a typo (e.g. ``--repare`` instead of ``--repair`` -- the
1074
+ pre-review behaviour shipped diagnostics that ignored the typo,
1075
+ masking the fact that the user never opted into repair).
1076
+ """
1077
+ flags = {
1078
+ "session": False,
1079
+ "fix": False,
1080
+ "json": False,
1081
+ "quiet": False,
1082
+ "full": False,
1083
+ "help": False,
1084
+ "project_root": None,
1085
+ "unknown": [],
1086
+ }
1087
+ i = 0
1088
+ while i < len(args):
1089
+ token = args[i]
1090
+ if token == "--session":
1091
+ flags["session"] = True
1092
+ elif token in ("--fix", "--repair", "--repair-taskfile"):
1093
+ flags["fix"] = True
1094
+ elif token == "--json":
1095
+ flags["json"] = True
1096
+ elif token == "--quiet":
1097
+ flags["quiet"] = True
1098
+ elif token == "--full":
1099
+ # #1308: bypass the 24h/4h throttle and always run the full
1100
+ # check. Operators reach for this when the prior run was
1101
+ # dirty (errors) and they want to re-probe after fixing,
1102
+ # OR when they want to re-confirm a clean run before
1103
+ # publishing a swarm.
1104
+ flags["full"] = True
1105
+ elif token in ("-h", "--help"):
1106
+ flags["help"] = True
1107
+ elif token == "--project-root":
1108
+ if i + 1 >= len(args):
1109
+ flags["unknown"].append("--project-root (missing value)")
1110
+ else:
1111
+ i += 1
1112
+ flags["project_root"] = args[i]
1113
+ elif token.startswith("--project-root="):
1114
+ value = token.split("=", 1)[1]
1115
+ if value:
1116
+ flags["project_root"] = value
1117
+ else:
1118
+ flags["unknown"].append("--project-root= (empty value)")
1119
+ else:
1120
+ flags["unknown"].append(token)
1121
+ i += 1
1122
+ return flags
1123
+
1124
+
1125
+ # Allowed flag set for ``run doctor`` -- surfaced in the error message
1126
+ # emitted when ``_parse_doctor_flags`` collects an unknown token (#1303
1127
+ # review correctness #3). Keep in sync with the registered branches in
1128
+ # :func:`_parse_doctor_flags`.
1129
+ _DOCTOR_ALLOWED_FLAGS = (
1130
+ "--session",
1131
+ "--fix",
1132
+ "--repair",
1133
+ "--repair-taskfile",
1134
+ "--json",
1135
+ "--quiet",
1136
+ "--full",
1137
+ "--project-root",
1138
+ "-h",
1139
+ "--help",
1140
+ )
1141
+
1142
+
1143
+ def _load_doctor_state_module():
1144
+ """Lazy-import ``scripts/_doctor_state`` (#1308)."""
1145
+ try:
1146
+ # Inside scripts/doctor.py, get_script_dir() already returns the
1147
+ # scripts/ dir containing sibling _doctor_state.py. Do not append
1148
+ # another "/scripts" (would resolve to scripts/scripts/ and break
1149
+ # throttle state load when doctor.py is the entry point).
1150
+ scripts_dir = get_script_dir()
1151
+ if str(scripts_dir) not in sys.path:
1152
+ sys.path.insert(0, str(scripts_dir))
1153
+ import _doctor_state # type: ignore[import-not-found]
1154
+ return _doctor_state
1155
+ except Exception: # noqa: BLE001 -- state load MUST NOT break doctor
1156
+ return None
1157
+
1158
+
1159
+ def _evaluate_doctor_throttle(project_root: Path):
1160
+ """Read doctor state and compute the 24h/4h throttle decision (#1308)."""
1161
+ mod = _load_doctor_state_module()
1162
+ if mod is None:
1163
+ return None
1164
+ try:
1165
+ state = mod.read_state(project_root)
1166
+ return mod.decide_throttle(state)
1167
+ except Exception: # noqa: BLE001 -- state read MUST NOT break doctor
1168
+ return None
1169
+
1170
+
1171
+ # --- Ported from run (required by cmd_doctor / freshness / throttle paths) ---
1172
+ # These were left behind during the initial extraction; without them every
1173
+ # `run doctor` (non-throttled path) hits NameError before any check runs.
1174
+ # Small batch ports; supporting constants/defs included where referenced.
1175
+
1176
+ # Minimal local read_yn (used only in interactive --fix Taskfile repair path
1177
+ # under isatty + fix_mode). Closes the "undefined" gap Greptile summary
1178
+ # flagged on the post-7a0606c head. Full ask_confirm lives in run; this is
1179
+ # the smallest non-crashing implementation sufficient for doctor.
1180
+ def read_yn(prompt_text: str, default: bool = False) -> bool:
1181
+ """Yes/No prompt (read_yn alias to run's ask_confirm)."""
1182
+ try:
1183
+ suffix = " (Y/n): " if default else " (y/N): "
1184
+ resp = input(f"{prompt_text}{suffix}").strip().lower()
1185
+ if not resp:
1186
+ return default
1187
+ return resp[0] in ("y", "yes")
1188
+ except (EOFError, KeyboardInterrupt):
1189
+ return default
1190
+
1191
+
1192
+ def _load_agents_md_module():
1193
+ """Lazy-import the shared ``scripts/_agents_md`` helpers (#1389).
1194
+
1195
+ ``get_script_dir()`` already returns the ``scripts/`` directory holding
1196
+ the sibling ``_agents_md.py``, so mirror ``_load_doctor_state_module``
1197
+ and insert it on ``sys.path`` before importing. The freshness probe can
1198
+ then share ``run``'s exact managed-section verdict logic instead of the
1199
+ interim stub that always reported ``unreadable``.
1200
+ """
1201
+ scripts_dir = get_script_dir()
1202
+ if str(scripts_dir) not in sys.path:
1203
+ sys.path.insert(0, str(scripts_dir))
1204
+ import _agents_md # type: ignore[import-not-found]
1205
+ return _agents_md
1206
+
1207
+
1208
+ def _agents_refresh_plan(project_root: Path) -> dict:
1209
+ """Compute the real AGENTS.md managed-section freshness verdict (#1389).
1210
+
1211
+ Delegates to the shared, pure ``scripts/_agents_md._agents_refresh_plan``
1212
+ -- the same implementation ``run`` uses -- so a consumer whose managed
1213
+ section is present, readable and current reports ``state == "current"``
1214
+ (no freshness warning) instead of the previous interim stub that
1215
+ unconditionally returned ``{"state": "unreadable"}`` and produced a
1216
+ spurious warning on every ``task doctor`` run. Genuinely stale sections
1217
+ report ``stale`` (the freshness check then points the operator at
1218
+ ``deft agents:refresh``); a genuinely unreadable / template-missing
1219
+ state still surfaces a warning.
1220
+ """
1221
+ return _load_agents_md_module()._agents_refresh_plan(project_root)
1222
+
1223
+
1224
+ def _now_utc() -> datetime:
1225
+ """Return UTC-aware ``datetime.now`` (split out for test monkeypatching)."""
1226
+ return datetime.now(UTC)
1227
+
1228
+
1229
+ # Post-#1875 content/ move: these framework-internal markers now live under
1230
+ # content/ in the SOURCE repo. They identify a deft source checkout (a consumer
1231
+ # would never reproduce them); the C1 flatten means a consumer deposit has no
1232
+ # content/ dir, so the absence of content/ here is itself consistent with the
1233
+ # "not a source checkout" branch.
1234
+ _DEFT_REPO_POSITIVE_MARKERS = (
1235
+ Path("content") / "templates" / "agents-entry.md",
1236
+ Path("content") / "skills" / "deft-directive-build" / "SKILL.md",
1237
+ )
1238
+
1239
+
1240
+ def _running_inside_deft_repo(project_root: Path) -> bool:
1241
+ """Heuristic: True when `run` is invoked from inside the deft repo itself.
1242
+
1243
+ Consumer projects embed deft as ``./deft/`` (legacy) or ``./.deft/core/``
1244
+ (canonical) and consume the framework's published surface; the deft
1245
+ source repo carries ``main.md`` at its root, has neither install
1246
+ location materialised inside its own checkout, AND ships a set of
1247
+ framework-internal artefacts (notably ``templates/agents-entry.md`` and
1248
+ ``skills/deft-directive-build/SKILL.md``) a consumer would have no
1249
+ reason to mirror.
1250
+
1251
+ The heuristic fires only when ALL of the following hold:
1252
+ * ``main.md`` is present at ``project_root`` (the documented entry
1253
+ point a consumer never reproduces verbatim).
1254
+ * NEITHER ``./deft`` (legacy install) NOR ``./.deft/core`` (canonical
1255
+ install) exists at the project root -- both indicate the deft
1256
+ framework was installed INTO this directory rather than that this
1257
+ directory IS the framework.
1258
+ * ALL of the markers in ``_DEFT_REPO_POSITIVE_MARKERS`` resolve --
1259
+ framework-internal paths a consumer would never reproduce.
1260
+
1261
+ The original heuristic (#1272 baseline) checked only ``main.md`` plus
1262
+ the absence of ``./deft``; that mis-fired on a consumer who happened
1263
+ to carry a root-level ``main.md`` for unrelated reasons OR who
1264
+ installed canonically to ``./.deft/core`` and so genuinely had no
1265
+ ``./deft`` subdirectory -- doctor would then silently skip the
1266
+ Taskfile-include diagnostic in exactly the place it was meant to
1267
+ surface (#1303 review SLizard P1, Greptile carryover).
1268
+
1269
+ Skipping the gate here avoids nagging deft maintainers on every
1270
+ ``run`` invocation against the framework checkout itself.
1271
+ """
1272
+ if not (project_root / "main.md").is_file():
1273
+ return False
1274
+ if (project_root / "deft").is_dir():
1275
+ return False
1276
+ if (project_root / ".deft" / "core").is_dir():
1277
+ return False
1278
+ return all((project_root / marker).is_file() for marker in _DEFT_REPO_POSITIVE_MARKERS)
1279
+
1280
+
1281
+ # --- Extracted doctor logic (from run, markers removed, now owned here) ---
1282
+
1283
+ def _format_iso_z(when) -> str:
1284
+ """Render a UTC-aware datetime as YYYY-MM-DDTHH:MM:SSZ."""
1285
+ if when is None:
1286
+ return ""
1287
+ if when.tzinfo is None:
1288
+ when = when.replace(tzinfo=UTC)
1289
+ return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
1290
+
1291
+
1292
+ def _render_doctor_status_line(decision) -> str:
1293
+ """Render the human-readable throttle-skip line (#1308)."""
1294
+ age_h = max(int(decision.age_hours), 0)
1295
+ if decision.dirty:
1296
+ errs = decision.last_error_count
1297
+ warns = max(decision.last_finding_count - decision.last_error_count, 0)
1298
+ err_phrase = f"{errs} error{'s' if errs != 1 else ''}"
1299
+ warn_phrase = f"{warns} warning{'s' if warns != 1 else ''}"
1300
+ return (
1301
+ f"[doctor] ran {age_h}h ago, {err_phrase} / {warn_phrase} "
1302
+ "-- UNRESOLVED; run `deft doctor --full` to re-probe or "
1303
+ "address findings."
1304
+ )
1305
+ remaining = decision.next_eligible_at - _now_utc()
1306
+ remaining_h = max(int(remaining.total_seconds() // 3600), 0)
1307
+ return (
1308
+ f"[doctor] ran {age_h}h ago, clean; next eligible in "
1309
+ f"{remaining_h}h; --full forces."
1310
+ )
1311
+
1312
+
1313
+ def _emit_doctor_throttle_skip(decision, *, json_mode: bool) -> int:
1314
+ """Print the throttle-skip surface and return the gated exit code (#1308)."""
1315
+ hint = (
1316
+ "run `deft doctor --full` to re-probe or address findings"
1317
+ if decision.dirty
1318
+ else "--full forces"
1319
+ )
1320
+ if json_mode:
1321
+ payload = {
1322
+ "status": "throttle-skipped",
1323
+ "last_run_at": _format_iso_z(decision.last_run_at),
1324
+ "last_exit_code": decision.last_exit_code,
1325
+ "last_error_count": decision.last_error_count,
1326
+ "last_finding_count": decision.last_finding_count,
1327
+ "next_eligible_at": _format_iso_z(decision.next_eligible_at),
1328
+ "hint": hint,
1329
+ }
1330
+ print(json.dumps(payload, sort_keys=True))
1331
+ else:
1332
+ print(_render_doctor_status_line(decision))
1333
+ return 1 if decision.dirty else 0
1334
+
1335
+
1336
+ def _persist_doctor_state(
1337
+ project_root: Path,
1338
+ *,
1339
+ exit_code: int,
1340
+ findings: list[dict],
1341
+ ) -> None:
1342
+ """Best-effort write of doctor-state.json after a full check (#1308).
1343
+
1344
+ ``last_finding_count`` is persisted as the count of findings that
1345
+ *mattered* -- ``severity == "skip"`` findings (e.g. the AGENTS.md
1346
+ freshness check's "no managed-section markers (likely maintainer
1347
+ repo)" skip) are EXCLUDED (#1316). Counting a skip would make
1348
+ ``_render_doctor_status_line`` over-report warnings by one on a
1349
+ dirty throttle-skip, because it derives the warning tally as
1350
+ ``last_finding_count - last_error_count`` and a skip carries no
1351
+ error/warning weight. See ``scripts/_doctor_state.py`` for the
1352
+ persisted-schema contract.
1353
+ """
1354
+ mod = _load_doctor_state_module()
1355
+ if mod is None:
1356
+ return
1357
+ try:
1358
+ mod.write_state(
1359
+ project_root,
1360
+ exit_code=int(exit_code),
1361
+ finding_count=sum(
1362
+ 1 for f in findings if f.get("severity") != "skip"
1363
+ ),
1364
+ error_count=sum(1 for f in findings if f.get("severity") == "error"),
1365
+ )
1366
+ except Exception: # noqa: BLE001 -- state write MUST NOT break doctor
1367
+ return
1368
+
1369
+
1370
+ def _run_install_integrity_checks(
1371
+ project_root: Path,
1372
+ *,
1373
+ emit_success,
1374
+ emit_warn,
1375
+ emit_error,
1376
+ emit_info,
1377
+ add_finding,
1378
+ ) -> None:
1379
+ """Install-integrity checks (ex-framework_doctor.py) folded into
1380
+ canonical doctor (#1308, #1336 retirement).
1381
+ """
1382
+ if _running_inside_deft_repo(project_root):
1383
+ emit_info(
1384
+ "Skipping install-integrity checks -- running inside the deft "
1385
+ "framework repo (no install manifest in the source checkout)."
1386
+ )
1387
+ return
1388
+ try:
1389
+ # Direct call to the local (ported) implementation -- no self-import
1390
+ # hack, no path munging. The four checks now run for real.
1391
+ result = run_checks(project_root)
1392
+ except Exception as exc: # noqa: BLE001 -- probe failure is a warning
1393
+ message = f"Install-integrity probe unavailable: {type(exc).__name__}: {exc}"
1394
+ emit_warn(message)
1395
+ add_finding("warning", message, check="install-integrity")
1396
+ return
1397
+ for entry in result.get("checks", []) or []:
1398
+ name = entry.get("name", "install-integrity")
1399
+ status = entry.get("status", "")
1400
+ detail = entry.get("detail", "")
1401
+ if status == "pass":
1402
+ emit_success(f"{name}: pass")
1403
+ continue
1404
+ if status == "skip":
1405
+ emit_info(f"{name}: skip -- {detail}")
1406
+ continue
1407
+ if status == "error":
1408
+ emit_error(f"{name}: error -- {detail}")
1409
+ else:
1410
+ emit_error(f"{name}: fail -- {detail}")
1411
+ add_finding(
1412
+ "error",
1413
+ detail or f"{name} {status}",
1414
+ check=f"install-integrity:{name}",
1415
+ install_check=name,
1416
+ status=status,
1417
+ data=entry.get("data", {}),
1418
+ )
1419
+
1420
+
1421
+ def _has_v3_managed_marker(project_root: Path) -> bool:
1422
+ """True iff AGENTS.md carries a deft:managed-section v3 marker (#1308)."""
1423
+ agents_md = project_root / "AGENTS.md"
1424
+ if not agents_md.is_file():
1425
+ return False
1426
+ try:
1427
+ text = agents_md.read_text(encoding="utf-8", errors="replace")
1428
+ except OSError:
1429
+ return False
1430
+ return re.search(
1431
+ r"<!--\s*deft:managed-section\s+v3(?:\s+[^>]*?)?\s*-->",
1432
+ text,
1433
+ ) is not None
1434
+
1435
+
1436
+ def _run_agents_md_freshness_check(
1437
+ project_root: Path,
1438
+ *,
1439
+ emit_success,
1440
+ emit_warn,
1441
+ emit_info,
1442
+ add_finding,
1443
+ ) -> None:
1444
+ """Probe AGENTS.md managed-section freshness via cmd_agents_refresh internals (#1308)."""
1445
+ check_name = "agents-md-managed-section-fresh"
1446
+ if _running_inside_deft_repo(project_root) or not _has_v3_managed_marker(
1447
+ project_root
1448
+ ):
1449
+ skip_reason = "no managed-section markers (likely maintainer repo)"
1450
+ emit_info(f"{check_name}: skip -- {skip_reason}")
1451
+ add_finding(
1452
+ "skip",
1453
+ skip_reason,
1454
+ check=check_name,
1455
+ status="skip",
1456
+ )
1457
+ return
1458
+ try:
1459
+ plan = _agents_refresh_plan(project_root)
1460
+ except Exception as exc: # noqa: BLE001 -- never break doctor
1461
+ message = f"{check_name}: probe failed -- {type(exc).__name__}: {exc}"
1462
+ emit_warn(message)
1463
+ add_finding("warning", message, check=check_name)
1464
+ return
1465
+ state = plan.get("state", "")
1466
+ if state == "current":
1467
+ emit_success(f"{check_name}: current")
1468
+ return
1469
+ if state in ("stale", "missing", "absent"):
1470
+ message = (
1471
+ f"AGENTS.md managed section is {state} -- "
1472
+ "run `deft agents:refresh` to bring it to the current template."
1473
+ )
1474
+ emit_warn(message)
1475
+ add_finding(
1476
+ "warning",
1477
+ message,
1478
+ check=check_name,
1479
+ status=state,
1480
+ suggestion="deft agents:refresh",
1481
+ )
1482
+ return
1483
+ message = (
1484
+ f"AGENTS.md freshness check could not run (state={state!r}). "
1485
+ "Inspect the framework template or AGENTS.md file permissions."
1486
+ )
1487
+ emit_warn(message)
1488
+ add_finding("warning", message, check=check_name, status=state)
1489
+
1490
+
1491
+ def _run_payload_staleness_check(
1492
+ project_root: Path,
1493
+ *,
1494
+ emit_warn,
1495
+ emit_info,
1496
+ add_finding,
1497
+ ) -> None:
1498
+ """#1339 (Epic-5): Detect when the installed framework payload is behind its
1499
+ manifest-recorded ref/sha. Reads the canonical <deftDir>/VERSION manifest
1500
+ (single source of truth per #1062), resolves the corresponding remote SHA
1501
+ via git ls-remote, and surfaces the canonical headless upgrade command
1502
+ `deft-install --yes --upgrade --repo-root . --json` (#1409) when the shas
1503
+ diverge. Skips gracefully inside the deft repo or when git / network /
1504
+ manifest unavailable (non-fatal, best-effort).
1505
+ """
1506
+ check_name = "payload-staleness"
1507
+ # Self-contained "inside deft repo" probe (avoids dependency on private
1508
+ # _running_inside_deft_repo helper that may be scoped inside cmd_doctor).
1509
+ try:
1510
+ agents = project_root / "AGENTS.md"
1511
+ is_deft = agents.exists() and (
1512
+ "Deft — Development Framework (deft repo)" in
1513
+ agents.read_text(encoding="utf-8", errors="ignore")
1514
+ )
1515
+ if is_deft:
1516
+ emit_info(f"{check_name}: skip -- running inside deft framework repo")
1517
+ add_finding(
1518
+ "skip", "inside framework repo (no install manifest)",
1519
+ check=check_name, status="skip",
1520
+ )
1521
+ return
1522
+ except Exception:
1523
+ pass
1524
+
1525
+ # Locate a plausible manifest. Prefer the one next to the scripts/doctor.py
1526
+ # we are running from (when invoked via the installed layout); fall back to
1527
+ # common canonical/legacy locations under project_root.
1528
+ manifest_path = None
1529
+ try:
1530
+ # When doctor.py lives at <deftDir>/scripts/doctor.py the manifest is at <deftDir>/VERSION
1531
+ candidate = get_script_dir().parent / "VERSION"
1532
+ if candidate.exists():
1533
+ manifest_path = candidate
1534
+ except Exception:
1535
+ pass
1536
+ if manifest_path is None:
1537
+ # #1427: probe canonical-first via the shared helper so a
1538
+ # webinstaller-vendored ``.deft/VERSION`` manifest is found too
1539
+ # (the prior list probed only ``.deft/core/VERSION`` and legacy
1540
+ # ``deft/VERSION``).
1541
+ manifest_path = _locate_manifest(project_root, None)
1542
+ if manifest_path is None:
1543
+ # Legacy bare marker -- not a full manifest, but the last-resort
1544
+ # provenance source for a pre-v0.28 install. Kept out of
1545
+ # ``_locate_manifest`` because that helper returns VERSION-manifest
1546
+ # paths only.
1547
+ legacy_marker = project_root / ".deft-version"
1548
+ if legacy_marker.exists():
1549
+ manifest_path = legacy_marker
1550
+ if manifest_path is None or not manifest_path.exists():
1551
+ emit_info(f"{check_name}: skip -- no install manifest found (pre-v0.28 or legacy state)")
1552
+ add_finding("skip", "no manifest", check=check_name, status="skip")
1553
+ return
1554
+
1555
+ try:
1556
+ text = manifest_path.read_text(encoding="utf-8", errors="replace")
1557
+ manifest = _parse_install_manifest(text)
1558
+ except Exception as exc: # noqa: BLE001
1559
+ emit_info(f"{check_name}: skip -- could not read manifest: {exc}")
1560
+ add_finding("skip", f"manifest unreadable: {exc}", check=check_name, status="skip")
1561
+ return
1562
+
1563
+ installed_sha = manifest.get("sha", "").strip()
1564
+ # Greptile P1 on #1384: do NOT fall back to "HEAD" when ref/tag are
1565
+ # absent. `git ls-remote origin HEAD` returns the current remote
1566
+ # default-branch tip, which almost certainly differs from the locally
1567
+ # installed sha for development builds without a ref/tag pinned, and
1568
+ # the check would then emit a permanent false-stale warning. Skip
1569
+ # cleanly when the manifest does not declare a ref/tag.
1570
+ ref = (manifest.get("ref") or manifest.get("tag") or "").strip()
1571
+ if not installed_sha:
1572
+ emit_info(f"{check_name}: skip -- manifest has no sha (incomplete provenance)")
1573
+ add_finding("skip", "no sha in manifest", check=check_name, status="skip")
1574
+ return
1575
+ if not ref:
1576
+ emit_info(
1577
+ f"{check_name}: skip -- manifest has no ref or tag (cannot resolve remote sha)"
1578
+ )
1579
+ add_finding("skip", "no ref/tag in manifest", check=check_name, status="skip")
1580
+ return
1581
+
1582
+ # Resolve current remote SHA for the ref (best effort, may be tag or branch).
1583
+ # Use ls-remote to avoid needing a local fetch or modifying state.
1584
+ try:
1585
+ # Determine the deft dir from manifest location (parent of VERSION)
1586
+ deft_dir = manifest_path.parent
1587
+ # ls-remote origin <ref> (works for branches and tags)
1588
+ proc = subprocess.run(
1589
+ ["git", "-C", str(deft_dir), "ls-remote", "origin", ref],
1590
+ capture_output=True,
1591
+ text=True,
1592
+ timeout=15,
1593
+ )
1594
+ if proc.returncode != 0:
1595
+ emit_info(f"{check_name}: skip -- git ls-remote failed (no network or no origin)")
1596
+ add_finding("skip", "ls-remote unavailable", check=check_name, status="skip")
1597
+ return
1598
+ # Output is "<sha>\t<refname>"
1599
+ # For annotated tags, ls-remote returns TWO lines:
1600
+ # <tag-object-sha> refs/tags/<tag>
1601
+ # <commit-sha> refs/tags/<tag>^{}
1602
+ # Prefer the peeled ^{} commit SHA when present (the one that matches
1603
+ # what the installer recorded in the manifest). Fall back to first line.
1604
+ # See Greptile P1 on #1384 (annotated-tag false-positive staleness).
1605
+ remote_sha = ""
1606
+ peeled_sha = ""
1607
+ for line in proc.stdout.splitlines():
1608
+ parts = line.strip().split()
1609
+ if len(parts) >= 2:
1610
+ refname = parts[1]
1611
+ if refname.endswith("^{}"):
1612
+ peeled_sha = parts[0]
1613
+ elif not remote_sha:
1614
+ remote_sha = parts[0]
1615
+ if peeled_sha:
1616
+ remote_sha = peeled_sha
1617
+ elif not remote_sha:
1618
+ # last-resort: first token of first line
1619
+ first_line = next((ln for ln in proc.stdout.splitlines() if ln.strip()), "")
1620
+ parts = first_line.strip().split()
1621
+ if parts:
1622
+ remote_sha = parts[0]
1623
+ if not remote_sha:
1624
+ emit_info(f"{check_name}: skip -- ls-remote produced no sha")
1625
+ add_finding("skip", "no remote sha", check=check_name, status="skip")
1626
+ return
1627
+ except Exception as exc: # noqa: BLE001 -- network/git optional
1628
+ emit_info(f"{check_name}: skip -- could not probe remote ({type(exc).__name__})")
1629
+ add_finding("skip", f"remote probe failed: {exc}", check=check_name, status="skip")
1630
+ return
1631
+
1632
+ if installed_sha == remote_sha:
1633
+ # Current
1634
+ emit_info(f"{check_name}: current (sha matches remote)")
1635
+ return
1636
+
1637
+ # Stale! Emit the EXACT canonical headless upgrade command (#1409) so a
1638
+ # normal consumer can copy-paste one line and end up with a fresh payload
1639
+ # plus updated metadata -- not just the metadata-only `task upgrade` ack.
1640
+ recommended_command = "deft-install --yes --upgrade --repo-root . --json"
1641
+ msg = (
1642
+ f"Framework payload is stale (installed sha {installed_sha[:8]}... "
1643
+ f"behind remote {remote_sha[:8]}... for ref '{ref}'). "
1644
+ f"Recommendation: run the canonical headless upgrader "
1645
+ f"`{recommended_command}` from your project root to pull the latest "
1646
+ f"payload (drop `--json` for human-readable output). On an installer "
1647
+ f"binary predating the headless flags, download the latest deft-install "
1648
+ f"from GitHub Releases first."
1649
+ )
1650
+ emit_warn(msg)
1651
+ add_finding(
1652
+ "warning",
1653
+ msg,
1654
+ check=check_name,
1655
+ status="stale",
1656
+ installed_sha=installed_sha,
1657
+ remote_sha=remote_sha,
1658
+ ref=ref,
1659
+ suggestion=recommended_command,
1660
+ )
1661
+
1662
+
1663
+ def _parse_install_manifest(text: str) -> dict:
1664
+ """Tiny tolerant parser for the single-key: 'value' YAML shape used by the
1665
+ install manifest (#1062). Mirrors the shape expected by run::_parse_install_manifest
1666
+ but kept local here so scripts/doctor.py stays self-contained for the handoff.
1667
+ """
1668
+ data: dict[str, str] = {}
1669
+ for line in text.splitlines():
1670
+ line = line.strip()
1671
+ if not line or ":" not in line:
1672
+ continue
1673
+ k, v = [x.strip() for x in line.split(":", 1)]
1674
+ v = v.strip().strip("'\"")
1675
+ data[k] = v
1676
+ return data
1677
+
1678
+ def cmd_doctor(args: list[str]):
1679
+ """Thin shim (#1335) -- core doctor logic now owned by scripts/doctor.py.
1680
+
1681
+ This entry point (and therefore `task doctor`) is a thin delegation layer.
1682
+ The implementation, modes (--session, reporting, --json, --fix, --quiet,
1683
+ --full, --project-root), throttle, and checks live in scripts/doctor.py
1684
+ (the single owner per Epic-1). During the carve transition the bodies
1685
+ remain in this file for stability; scripts/doctor.py is the documented
1686
+ import surface and will receive the logic in follow-on increments.
1687
+
1688
+ See scripts/doctor.py header + vbrief/active/*1335*.vbrief.json .
1689
+ """
1690
+
1691
+ # Real implementation body follows (transition). After full extraction
1692
+ # this will be a 4-line import + call to scripts.doctor.cmd_doctor.
1693
+ # The body below is the current home (being migrated).
1694
+ """Canonical doctor surface for task-surface health (#1272, #1303 review).
1695
+
1696
+ Diagnoses (and optionally repairs, with explicit consent):
1697
+
1698
+ 1. Required tools on PATH (uv, git) and optional tools (task,
1699
+ python3, go, node) -- the existing #792 dependency probe.
1700
+ 2. Expected framework directory layout (#792).
1701
+ 3. Consumer root Taskfile.yml include health (#1272). When run
1702
+ inside a consumer project, doctor detects:
1703
+ * missing root Taskfile.yml -> diagnose + print snippet;
1704
+ interactive ``--fix``
1705
+ may CREATE the file after
1706
+ explicit operator consent.
1707
+ * root Taskfile.yml exists, no -> diagnose + print snippet;
1708
+ deft include NEVER mutates the existing
1709
+ user-owned Taskfile.
1710
+ * include present -> OK.
1711
+
1712
+ Flags (parsed via :func:`_parse_doctor_flags`):
1713
+ ``--session`` diagnose-only, session-safe; no prompt, no
1714
+ mutation.
1715
+ ``--fix`` interactive repair offered when actionable
1716
+ (Taskfile creation only); ignored under
1717
+ ``--session``.
1718
+ ``--json`` emit a single JSON object on stdout and
1719
+ suppress the human-readable prose; exit
1720
+ code unchanged.
1721
+ ``--quiet`` suppress per-check success lines; errors
1722
+ and warnings still surface.
1723
+ ``--project-root`` override the project root used for the
1724
+ Taskfile diagnostic. Defaults to
1725
+ :func:`Path.cwd`.
1726
+
1727
+ Returns:
1728
+ ``0`` on a clean check OR a warning-only check (warnings are
1729
+ informational and never exit-failing).
1730
+ ``1`` on a hard error (missing required tool OR Taskfile drift
1731
+ detected).
1732
+ ``2`` on argument-parse failure (an unknown flag was passed --
1733
+ the doctor refuses to run the diagnostics so the typo cannot
1734
+ masquerade as a clean check).
1735
+
1736
+ Non-zero return is informational -- doctor's role is to surface
1737
+ the failure, not to block the upgrade gate.
1738
+
1739
+ Throttle-state count semantics (#1316): when a full run completes,
1740
+ ``_persist_doctor_state`` writes ``last_finding_count`` as the count
1741
+ of findings that *mattered* -- ``severity == "skip"`` findings are
1742
+ EXCLUDED. A skip (e.g. the AGENTS.md freshness check reporting "no
1743
+ managed-section markers (likely maintainer repo)") is neither an
1744
+ error nor a warning, so counting it would make the next throttle-skip
1745
+ status line over-report warnings by one (the line derives warnings as
1746
+ ``last_finding_count - last_error_count``). The in-run ``--json``
1747
+ ``summary`` block already counts only ``error`` / ``warning``
1748
+ findings, so this keeps the persisted tally consistent with it.
1749
+ """
1750
+ flags = _parse_doctor_flags(args)
1751
+
1752
+ # Reject unknown flags loudly. The previous shape silently swallowed
1753
+ # typos (`--repare` instead of `--repair`), so an operator who
1754
+ # mistyped never realised they had not opted into repair -- the
1755
+ # diagnostic still ran in default mode and the prose suggested the
1756
+ # repair was offered. Surface the unknown tokens, list the allowed
1757
+ # set, and exit 2 so CI wrappers can distinguish a malformed
1758
+ # invocation from a real diagnostic failure (#1303 review #3).
1759
+ if flags.get("unknown"):
1760
+ error(
1761
+ "Unknown flag(s): "
1762
+ + ", ".join(flags["unknown"])
1763
+ )
1764
+ info(
1765
+ "Allowed: " + ", ".join(_DOCTOR_ALLOWED_FLAGS)
1766
+ )
1767
+ return 2
1768
+
1769
+ session_mode = flags["session"]
1770
+ fix_mode = flags["fix"] and not session_mode
1771
+ json_mode = flags["json"]
1772
+ quiet_mode = flags["quiet"]
1773
+ full_mode = flags["full"]
1774
+
1775
+ # ``--project-root`` lets operators invoke doctor against an
1776
+ # arbitrary directory rather than ``Path.cwd``. Defaults to the
1777
+ # current working directory so existing callers (``task doctor``,
1778
+ # the ``run doctor`` CLI without overrides) are unaffected. The
1779
+ # path is normalised through :func:`resolve_path` so ``~`` and
1780
+ # relative paths work (#1303 review #5).
1781
+ project_root_arg = flags.get("project_root")
1782
+ project_root = (
1783
+ resolve_path(project_root_arg) if project_root_arg else Path.cwd()
1784
+ )
1785
+
1786
+ # #1308: throttle gate. Default = full check, but a recent run
1787
+ # within the 24h-clean / 4h-dirty window short-circuits to a
1788
+ # one-line status surface. ``--full`` bypasses the throttle. The
1789
+ # ritual halts on a dirty-within-window state (exit 1) so a
1790
+ # persistent-dirty install is never silently ignored.
1791
+ if not full_mode:
1792
+ decision = _evaluate_doctor_throttle(project_root)
1793
+ if decision is not None and decision.skip:
1794
+ return _emit_doctor_throttle_skip(decision, json_mode=json_mode)
1795
+
1796
+ # Findings are the single source of truth for the summary, the
1797
+ # JSON payload, and the exit code (#1303 review #1 / #4). Replaces
1798
+ # the prior ``errors += 1`` / ``errors -= 1`` accounting pair that
1799
+ # was brittle when the interactive ``--fix`` path repaired a
1800
+ # missing-file finding -- the decrement coupled two unrelated
1801
+ # branches and made the summary easy to mis-read.
1802
+ findings: list[dict] = []
1803
+
1804
+ def _add_finding(severity: str, message: str, **extras: object) -> None:
1805
+ entry: dict = {"severity": severity, "message": message}
1806
+ entry.update(extras)
1807
+ findings.append(entry)
1808
+
1809
+ def _emit_info(msg: str) -> None:
1810
+ if not json_mode:
1811
+ info(msg)
1812
+
1813
+ def _emit_success(msg: str) -> None:
1814
+ if json_mode or quiet_mode:
1815
+ return
1816
+ success(msg)
1817
+
1818
+ def _emit_warn(msg: str) -> None:
1819
+ if not json_mode:
1820
+ warn(msg)
1821
+
1822
+ def _emit_error(msg: str) -> None:
1823
+ if not json_mode:
1824
+ error(msg)
1825
+
1826
+ if not json_mode:
1827
+ print_header(f"Deft CLI v{VERSION} - Doctor")
1828
+ print()
1829
+ _emit_info("Checking system dependencies...")
1830
+ if not json_mode:
1831
+ print()
1832
+
1833
+ # Check for required tools. Errors and warnings are tracked
1834
+ # separately (#792) so a missing required tool surfaces above
1835
+ # optional-tool warnings in the summary and forces a non-zero
1836
+ # return code.
1837
+ def check_command(cmd: str, name: str, required: bool = False,
1838
+ install_url: str = ""):
1839
+ if shutil.which(cmd):
1840
+ _emit_success(f"{name} is installed")
1841
+ return
1842
+ url_hint = f" - install: {install_url}" if install_url else ""
1843
+ if required:
1844
+ message = f"{name} not found - required{url_hint}"
1845
+ _emit_error(message)
1846
+ _add_finding(
1847
+ "error",
1848
+ message,
1849
+ check="dependency",
1850
+ tool=cmd,
1851
+ suggestion=install_url or None,
1852
+ )
1853
+ return
1854
+ if cmd == "task":
1855
+ message = f"{name} not found - install from https://taskfile.dev"
1856
+ else:
1857
+ message = f"{name} not found{url_hint}"
1858
+ _emit_warn(message)
1859
+ _add_finding(
1860
+ "warning",
1861
+ message,
1862
+ check="dependency",
1863
+ tool=cmd,
1864
+ suggestion=install_url or None,
1865
+ )
1866
+
1867
+ # uv is required: every deft task script invokes `uv run python ...`,
1868
+ # so a green doctor on a machine without uv would mask an adoption
1869
+ # blocker (#792). Surface it before optional tools so the error is
1870
+ # the first thing a fresh-machine user sees.
1871
+ check_command(
1872
+ "uv",
1873
+ "uv (Astral Python runner)",
1874
+ required=True,
1875
+ install_url=UV_INSTALL_URL,
1876
+ )
1877
+ check_command("git", "git", required=True)
1878
+ check_command("python3", "python3")
1879
+ check_command("go", "go")
1880
+ check_command("node", "node")
1881
+
1882
+ # #1308 / #1336: install-integrity checks now owned by scripts/doctor.py
1883
+ # (the four checks formerly in framework_doctor.py). cmd_doctor folds
1884
+ # them under ``install-integrity:<name>`` keys. Skipped in the deft
1885
+ # maintainer repo (no install manifest in the source checkout).
1886
+ if not json_mode:
1887
+ print()
1888
+ _emit_info("Checking install integrity...")
1889
+ _run_install_integrity_checks(
1890
+ project_root,
1891
+ emit_success=_emit_success,
1892
+ emit_warn=_emit_warn,
1893
+ emit_error=_emit_error,
1894
+ emit_info=_emit_info,
1895
+ add_finding=_add_finding,
1896
+ )
1897
+
1898
+ # #1308: AGENTS.md managed-section freshness. Reuses the
1899
+ # cmd_agents_refresh --check byte-compare via _agents_refresh_plan;
1900
+ # emits a skip finding with reason "no managed-section markers
1901
+ # (likely maintainer repo)" when AGENTS.md carries no v3 markers.
1902
+ # Stale templates surface as a warning (zero exit) -- the operator
1903
+ # runs `deft agents:refresh` to bring them current.
1904
+ if not json_mode:
1905
+ print()
1906
+ _emit_info("Checking AGENTS.md managed-section freshness...")
1907
+ _run_agents_md_freshness_check(
1908
+ project_root,
1909
+ emit_success=_emit_success,
1910
+ emit_warn=_emit_warn,
1911
+ emit_info=_emit_info,
1912
+ add_finding=_add_finding,
1913
+ )
1914
+
1915
+ # #1339 (Epic-5): payload staleness from the install manifest. Runs after
1916
+ # AGENTS freshness so the handoff from installer always surfaces a clear
1917
+ # "re-run the installer" recommendation when the cloned payload sha lags
1918
+ # the remote (deterministic, works in --session --json mode for agents).
1919
+ if not json_mode:
1920
+ print()
1921
+ _emit_info("Checking payload staleness from install manifest...")
1922
+ _run_payload_staleness_check(
1923
+ project_root,
1924
+ emit_warn=_emit_warn,
1925
+ emit_info=_emit_info,
1926
+ add_finding=_add_finding,
1927
+ )
1928
+
1929
+ # Check directory structure. Updated to the v0.20+ canonical
1930
+ # layout (#792); pre-v0.20 entries (core, interfaces, tools, swarm,
1931
+ # meta) were dropped because they no longer reflect the framework's
1932
+ # current top-level layout and produced spurious 'Missing directory'
1933
+ # warnings on every clean checkout. Cross-referenced with
1934
+ # `skills/deft-directive-setup/SKILL.md` § Environment Preflight
1935
+ # (vbrief lifecycle requirement) and the project tree on master.
1936
+ if not json_mode:
1937
+ print()
1938
+ _emit_info("Checking Deft structure...")
1939
+
1940
+ # Use .parent so the check anchors at the framework root (the directory
1941
+ # containing scripts/doctor.py), restoring the pre-extraction semantics
1942
+ # from run.get_script_dir() (which returned repo root in source layout).
1943
+ # This eliminates the false-positive "Missing directory" warnings for all
1944
+ # seven canonical framework subdirectories on every `run doctor` / `task doctor`
1945
+ # invocation (Greptile framework-layout issue on 7a0606c).
1946
+ framework_root = get_script_dir().parent
1947
+ # Post-#1875 content/ move: shippable content dirs live under content/ in
1948
+ # the SOURCE repo and are flattened back to the framework root in a CONSUMER
1949
+ # deposit (C1). ``content_root`` resolves both contexts; engine/lifecycle
1950
+ # dirs (tasks/, scripts/, vbrief/) always stay at the framework root.
1951
+ scripts_dir = get_script_dir()
1952
+ if str(scripts_dir) not in sys.path:
1953
+ sys.path.insert(0, str(scripts_dir))
1954
+ from _content_root import content_root # noqa: PLC0415
1955
+
1956
+ content_base = content_root(framework_root)
1957
+ expected_dirs = [
1958
+ ("languages", content_base),
1959
+ ("strategies", content_base),
1960
+ ("skills", content_base),
1961
+ ("templates", content_base),
1962
+ ("tasks", framework_root),
1963
+ ("scripts", framework_root),
1964
+ ("vbrief", framework_root),
1965
+ ]
1966
+
1967
+ for dir_name, base in expected_dirs:
1968
+ dir_path = base / dir_name
1969
+ if dir_path.is_dir():
1970
+ _emit_success(f"Directory: {dir_name}/")
1971
+ else:
1972
+ message = f"Missing directory: {dir_name}/"
1973
+ _emit_warn(message)
1974
+ _add_finding(
1975
+ "warning",
1976
+ message,
1977
+ check="framework-layout",
1978
+ directory=dir_name,
1979
+ )
1980
+
1981
+ # #1272 root Taskfile.yml include health. Skip when invoked from
1982
+ # inside the deft framework repo itself -- the deft repo's own
1983
+ # Taskfile.yml is the source of truth for its surface and does not
1984
+ # need (and must not declare) a `deft:` include to itself.
1985
+ if not json_mode:
1986
+ print()
1987
+ _emit_info("Checking optional root Taskfile.yml include...")
1988
+ if _running_inside_deft_repo(project_root):
1989
+ _emit_info(
1990
+ "Skipping Taskfile include check -- running inside the deft "
1991
+ "framework repo (the repo's own Taskfile.yml is the surface)."
1992
+ )
1993
+ else:
1994
+ # ``include_missing`` is True until a successful interactive
1995
+ # repair flips it off. Replaces the prior ``errors -= 1``
1996
+ # gymnastic on the missing-file branch (#1303 review #1).
1997
+ include_status = _classify_taskfile_include(project_root)
1998
+ if include_status == "ok":
1999
+ _emit_success("Root Taskfile.yml includes the deft framework")
2000
+ elif include_status == "missing-file":
2001
+ include_missing = True
2002
+ target = project_root / "Taskfile.yml"
2003
+ message = (
2004
+ "Root Taskfile.yml missing. This is OK for package-manager "
2005
+ "installs that use the `deft X` surface directly. To also "
2006
+ f"enable the optional `task deft:X` surface, paste this into {target}:"
2007
+ )
2008
+ _emit_info(message)
2009
+ if not json_mode:
2010
+ print()
2011
+ print(_TASKFILE_INCLUDE_SNIPPET)
2012
+ # Interactive repair path. All gates MUST hold before any
2013
+ # write: (1) --fix was requested AND we are not under
2014
+ # --session (both folded into ``fix_mode`` -- see
2015
+ # ``fix_mode = flags["fix"] and not session_mode`` above);
2016
+ # (2) stdin is a TTY (so we can prompt); (3) we are not
2017
+ # emitting JSON (JSON mode is diagnose-only). Even then,
2018
+ # the operator must explicitly approve at the prompt.
2019
+ # #1303 pass-3 review (Greptile run:4664-4669 -- redundant
2020
+ # session_mode guard): the prior shape repeated
2021
+ # ``and not session_mode`` here, but fix_mode already
2022
+ # incorporates that condition; the duplicate gate could
2023
+ # never change the outcome and invited confusion.
2024
+ if (
2025
+ fix_mode
2026
+ and not json_mode
2027
+ and sys.stdin.isatty()
2028
+ ):
2029
+ if read_yn(
2030
+ f"Create {target} with the canonical include now?",
2031
+ default=False,
2032
+ ):
2033
+ try:
2034
+ # ``newline="\n"`` enforces LF line endings on
2035
+ # every host -- ``write_text`` otherwise honours
2036
+ # the platform default, which produces CRLF on
2037
+ # Windows and breaks the byte-equality contract
2038
+ # tests rely on (#1303 review #6).
2039
+ target.write_text(
2040
+ _TASKFILE_INCLUDE_SNIPPET,
2041
+ encoding="utf-8",
2042
+ newline="\n",
2043
+ )
2044
+ _emit_success(f"Wrote {target}")
2045
+ # The drift was just repaired -- flip the
2046
+ # boolean so the summary reflects the
2047
+ # post-repair state (replaces the prior
2048
+ # ``errors -= 1`` decrement pair).
2049
+ include_missing = False
2050
+ except OSError as exc:
2051
+ _emit_error(f"Failed to write {target}: {exc}")
2052
+ else:
2053
+ _emit_info(
2054
+ "Skipped Taskfile.yml creation -- paste the "
2055
+ "snippet above when you are ready."
2056
+ )
2057
+ if include_missing:
2058
+ _add_finding(
2059
+ "warning",
2060
+ "Root Taskfile.yml missing; optional Taskfile include unavailable",
2061
+ check="taskfile-include",
2062
+ file=str(target),
2063
+ suggestion=_TASKFILE_INCLUDE_SNIPPET,
2064
+ )
2065
+ elif include_status == "missing-include":
2066
+ message = (
2067
+ "Root Taskfile.yml exists but does not include the deft "
2068
+ "framework. The `deft X` surface still works; add this to "
2069
+ "the Taskfile `includes:` block only if you want the optional "
2070
+ "`task deft:X` surface (doctor NEVER mutates an existing "
2071
+ "user-owned Taskfile):"
2072
+ )
2073
+ _emit_warn(message)
2074
+ if not json_mode:
2075
+ print()
2076
+ print(_format_missing_include_snippet())
2077
+ taskfile_path = _resolve_consumer_taskfile(project_root)
2078
+ _add_finding(
2079
+ "warning",
2080
+ "Root Taskfile.yml does not include the deft framework",
2081
+ check="taskfile-include",
2082
+ file=str(taskfile_path) if taskfile_path else None,
2083
+ suggestion=_format_missing_include_snippet(),
2084
+ )
2085
+ elif include_status == "unreadable":
2086
+ # Resolve the actual Taskfile path so a consumer who chose the
2087
+ # ``.yaml`` spelling sees the right file name in the error
2088
+ # message and in the JSON `file` field (#1303 review,
2089
+ # Greptile #2). Falls back to ``Taskfile.yml`` only if the
2090
+ # resolver returns None -- which shouldn't happen here
2091
+ # because the `unreadable` branch is only reached when a
2092
+ # candidate file was found, but the fallback keeps the
2093
+ # diagnostic informative under any future code drift.
2094
+ taskfile_path = (
2095
+ _resolve_consumer_taskfile(project_root)
2096
+ or (project_root / "Taskfile.yml")
2097
+ )
2098
+ message = (
2099
+ f"Root Taskfile.yml at {taskfile_path} "
2100
+ "exists but could not be read -- check file permissions."
2101
+ )
2102
+ _emit_warn(message)
2103
+ _add_finding(
2104
+ "warning",
2105
+ message,
2106
+ check="taskfile-include",
2107
+ file=str(taskfile_path),
2108
+ )
2109
+
2110
+ error_count = sum(1 for f in findings if f["severity"] == "error")
2111
+ warning_count = sum(1 for f in findings if f["severity"] == "warning")
2112
+ exit_code = 1 if error_count else 0
2113
+
2114
+ # #1308: persist doctor-state.json so the next invocation can
2115
+ # consult the throttle gate. Best-effort -- a write failure is
2116
+ # silently swallowed by the state module so the doctor itself
2117
+ # never breaks because of a state-file bug.
2118
+ _persist_doctor_state(
2119
+ project_root,
2120
+ exit_code=exit_code,
2121
+ findings=findings,
2122
+ )
2123
+
2124
+ if json_mode:
2125
+ payload = {
2126
+ "status": "completed",
2127
+ "ok": exit_code == 0,
2128
+ "findings": findings,
2129
+ "summary": {
2130
+ "errors": error_count,
2131
+ "warnings": warning_count,
2132
+ },
2133
+ "project_root": str(project_root),
2134
+ }
2135
+ print(json.dumps(payload, sort_keys=True))
2136
+ return exit_code
2137
+
2138
+ print()
2139
+ if error_count == 0 and warning_count == 0:
2140
+ success("System check passed!")
2141
+ return 0
2142
+ if error_count:
2143
+ # Errors first so missing-uv (or git) is not buried under
2144
+ # optional-tool warnings.
2145
+ error(
2146
+ f"System check failed with {error_count} error(s)"
2147
+ + (f" and {warning_count} warning(s)" if warning_count else "")
2148
+ + "."
2149
+ )
2150
+ return 1
2151
+ warn(f"System check completed with {warning_count} warning(s).")
2152
+ return 0
2153
+
2154
+ # (end of extracted region; now maintained in this file)
2155
+ # End of block extracted to scripts/doctor.py (see START marker above).
2156
+ # The thin shim below this point in the final state will replace the
2157
+ # extracted region.
2158
+ # ===
2159
+ # --- End of extracted doctor logic (Epic-1 #1335) ---
2160
+
2161
+ # --- Ported CLI surface (main, _build_parser, _format_text_report) from
2162
+ # retired framework_doctor.py to satisfy test expectations for fd.main(),
2163
+ # UTF-8 reconfigure (#814), --json/--quiet/--project-root, and the 3-state
2164
+ # exit codes. The primary user surface remains cmd_doctor (new extraction).
2165
+ # ---
2166
+
2167
+
2168
+ def _build_parser() -> argparse.ArgumentParser:
2169
+ parser = argparse.ArgumentParser(
2170
+ prog="framework_doctor.py",
2171
+ description=(
2172
+ "Local install-integrity probe (#1046 PR-B AC-3). Four checks: "
2173
+ "QUICK-START resolves, skill paths resolve, manifest agreement, "
2174
+ "install-path consistency. Three-state exit: 0 clean / 1 drift "
2175
+ "detected / 2 config error."
2176
+ ),
2177
+ )
2178
+ parser.add_argument(
2179
+ "--project-root",
2180
+ default=".",
2181
+ help="Project root path (default: current working directory).",
2182
+ )
2183
+ parser.add_argument(
2184
+ "--json",
2185
+ action="store_true",
2186
+ help="Emit a single JSON object on stdout instead of human-readable text.",
2187
+ )
2188
+ parser.add_argument(
2189
+ "--quiet",
2190
+ action="store_true",
2191
+ help="Suppress the success summary; failure detail still prints.",
2192
+ )
2193
+ return parser
2194
+
2195
+
2196
+ def _format_text_report(result: DoctorResult) -> str:
2197
+ """Render a human-readable summary of the doctor result."""
2198
+ lines: list[str] = []
2199
+ if result.exit_code == EXIT_CLEAN:
2200
+ lines.append(
2201
+ "\u2713 deft framework:doctor -- all checks pass "
2202
+ f"(install_root={result.install_root!r})."
2203
+ )
2204
+ elif result.exit_code == EXIT_DRIFT:
2205
+ lines.append(
2206
+ "\u26a0 deft framework:doctor -- drift detected "
2207
+ f"(install_root={result.install_root!r})."
2208
+ )
2209
+ else:
2210
+ lines.append("\u2717 deft framework:doctor -- config error.")
2211
+ for c in result.checks:
2212
+ if c.status == "pass":
2213
+ sym = "\u2713"
2214
+ elif c.status == "skip":
2215
+ sym = "\u2022"
2216
+ elif c.status == "fail":
2217
+ sym = "\u2717"
2218
+ else: # error
2219
+ sym = "!"
2220
+ lines.append(f" {sym} {c.name}: {c.detail}")
2221
+ for err in result.errors:
2222
+ lines.append(f" ! {err}")
2223
+ return "\n".join(lines)
2224
+
2225
+
2226
+ def main(argv: list[str] | None = None) -> int:
2227
+ # #814: Force UTF-8 stdout/stderr at script entry. Windows Python
2228
+ # defaults stdout/stderr to cp1252 when invoked under git hooks,
2229
+ # which has no glyph for the U+2713 success marker. Without this
2230
+ # reconfigure the doctor crashes with UnicodeEncodeError on the
2231
+ # success summary. Guarded by hasattr because reconfigure only
2232
+ # exists on TextIOWrapper streams. errors='replace' is a
2233
+ # belt-and-suspenders fallback for the rare environment that still
2234
+ # cannot render UTF-8.
2235
+ if hasattr(sys.stdout, "reconfigure"):
2236
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
2237
+ if hasattr(sys.stderr, "reconfigure"):
2238
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
2239
+
2240
+ parser = _build_parser()
2241
+ args = parser.parse_args(argv)
2242
+ project_root = Path(args.project_root).resolve()
2243
+ result = _run_checks_impl(project_root)
2244
+ if args.json:
2245
+ print(json.dumps(result.to_dict(), sort_keys=True))
2246
+ else:
2247
+ if not (args.quiet and result.exit_code == EXIT_CLEAN):
2248
+ print(_format_text_report(result))
2249
+ return result.exit_code
2250
+
2251
+
2252
+ if __name__ == "__main__":
2253
+ # python -m scripts.doctor [args] or direct python scripts/doctor.py [args]
2254
+ args = sys.argv[1:]
2255
+ if args and args[0].lower() == 'doctor':
2256
+ args = args[1:]
2257
+ sys.exit(cmd_doctor(args))