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