@deftai/directive-content 0.59.0 → 0.61.0

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