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