@deftai/directive-content 0.55.1 → 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.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,343 @@
1
+ """_relocate_states.py -- state detection helpers for scripts/relocate.py (#992 PR2).
2
+
3
+ Extracted from :mod:`scripts.relocate` to keep the parent module under the
4
+ deft 1000-line MUST limit (mirrors the
5
+ ``scripts/cache.py`` / ``scripts/_cache_validate.py`` /
6
+ ``scripts/_cache_fetch.py`` split pattern from #883).
7
+
8
+ Public API:
9
+
10
+ - :func:`detect_install_state` -- A/B/C/D/E/F/G classification.
11
+ - :func:`detect_active_swarm` -- True iff any vbrief/active is running.
12
+ - :func:`active_swarm_paths` -- list of running active vBRIEFs.
13
+ - :func:`is_framework_customized` -- True iff framework dir != source.
14
+ - :func:`customization_paths` -- list of customized files.
15
+ - :func:`advise_external_hardcodes` -- legacy ``deft/run`` grep.
16
+ - :func:`iter_files` -- recursive regular-file walker.
17
+
18
+ This module is intentionally pure-stdlib + pathlib so it imports cleanly
19
+ under the same UTF-8 + Python 3.11 baseline as the rest of ``scripts/``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import re
27
+ from collections.abc import Iterator
28
+ from pathlib import Path
29
+
30
+ # Mirror the constants used by the parent module verbatim so a future
31
+ # rename in scripts/relocate.py only needs to update one place. The
32
+ # parent module re-imports these at module load.
33
+ CANONICAL_FRAMEWORK_DIR: str = ".deft/core"
34
+ LEGACY_FRAMEWORK_DIR: str = "deft"
35
+
36
+ AGENTS_MANAGED_OPEN: str = "<!-- deft:managed-section v3 -->"
37
+ AGENTS_MANAGED_CLOSE: str = "<!-- /deft:managed-section -->"
38
+
39
+ # v2 -> v3 marker bump (#1046 PR-B AC-5). Detection MUST accept both forms
40
+ # for one release cycle so consumers on v0.27.x still classify as having a
41
+ # managed section after marker drift -- the relocator's regenerate path
42
+ # will rewrite them to v3. The bare ``v3`` form is the template literal;
43
+ # the regex's optional attribute group covers the per-refresh
44
+ # ``sha=<sha> refreshed=<iso> session=<id>`` tokens emitted by
45
+ # ``run::cmd_agents_refresh``.
46
+ _AGENTS_MANAGED_OPEN_RE = re.compile(
47
+ r"<!--\s*deft:managed-section\s+v(2|3)(?:\s+[^>]*?)?\s*-->"
48
+ )
49
+
50
+ ADVISORY_LEGACY_TOKEN: str = "deft/run"
51
+
52
+ ADVISORY_GREP_SKIP_DIRS: tuple[str, ...] = (
53
+ ".deft",
54
+ ".deft-cache",
55
+ LEGACY_FRAMEWORK_DIR,
56
+ ".git",
57
+ ".github",
58
+ ".venv",
59
+ ".pytest_cache",
60
+ "__pycache__",
61
+ "node_modules",
62
+ "dist",
63
+ "build",
64
+ )
65
+
66
+ ADVISORY_GREP_EXTENSIONS: frozenset[str] = frozenset(
67
+ {
68
+ ".md",
69
+ ".txt",
70
+ ".yml",
71
+ ".yaml",
72
+ ".json",
73
+ ".toml",
74
+ ".sh",
75
+ ".ps1",
76
+ ".bat",
77
+ ".py",
78
+ ".go",
79
+ ".js",
80
+ ".ts",
81
+ ".rs",
82
+ ".rb",
83
+ }
84
+ )
85
+
86
+ _ADVISORY_GREP_MAX_BYTES: int = 1_000_000
87
+
88
+
89
+ __all__ = [
90
+ "ADVISORY_GREP_EXTENSIONS",
91
+ "ADVISORY_GREP_SKIP_DIRS",
92
+ "ADVISORY_LEGACY_TOKEN",
93
+ "active_swarm_paths",
94
+ "advise_external_hardcodes",
95
+ "customization_paths",
96
+ "detect_active_swarm",
97
+ "detect_install_state",
98
+ "is_framework_customized",
99
+ "iter_files",
100
+ ]
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Filesystem walker
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def iter_files(root: Path) -> Iterator[Path]:
109
+ """Yield every regular file under ``root`` recursively (no symlinks)."""
110
+ if not root.is_dir():
111
+ return
112
+ for entry in root.iterdir():
113
+ if entry.is_dir() and not entry.is_symlink():
114
+ yield from iter_files(entry)
115
+ elif entry.is_file():
116
+ yield entry
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # State A / B / C / D / E / F / G classifier
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _has_managed_markers(text: str) -> bool:
125
+ # Accept both the v3 (canonical, #1046 PR-B AC-5) and v2 (legacy,
126
+ # one-release back-compat window) open markers so a v0.27.x consumer
127
+ # still classifies as state CANONICAL until the relocator rewrites the
128
+ # marker to v3. The close marker is shared across versions.
129
+ return (
130
+ _AGENTS_MANAGED_OPEN_RE.search(text) is not None
131
+ and AGENTS_MANAGED_CLOSE in text
132
+ )
133
+
134
+
135
+ def detect_install_state(
136
+ project_root: Path,
137
+ *,
138
+ framework_source: Path | None = None,
139
+ ) -> str:
140
+ """Classify the consumer install layout (A/B/C/D/E/F/G/CANONICAL).
141
+
142
+ State G (active swarm) and state E (customized framework) are
143
+ pre-flight gates that take precedence over the layout states A-D
144
+ when present. The relocator's plan-builder consults the customization
145
+ + active-swarm probes independently so the full state vector is
146
+ available even on layout state C / D / etc.
147
+ """
148
+ legacy = project_root / LEGACY_FRAMEWORK_DIR
149
+ canonical = project_root / CANONICAL_FRAMEWORK_DIR
150
+ agents_md = project_root / "AGENTS.md"
151
+ vbrief_root = project_root / "vbrief"
152
+
153
+ legacy_present = legacy.is_dir()
154
+ canonical_present = canonical.is_dir()
155
+ agents_md_present = agents_md.is_file()
156
+ vbrief_present = vbrief_root.is_dir()
157
+
158
+ if detect_active_swarm(project_root):
159
+ return "G"
160
+
161
+ if framework_source is not None and (
162
+ (legacy_present and is_framework_customized(legacy, framework_source))
163
+ or (canonical_present and is_framework_customized(canonical, framework_source))
164
+ ):
165
+ return "E"
166
+
167
+ if legacy_present and canonical_present:
168
+ return "C"
169
+ if legacy_present and not canonical_present:
170
+ return "A"
171
+ if canonical_present and not legacy_present:
172
+ if not agents_md_present:
173
+ return "B"
174
+ try:
175
+ text = agents_md.read_text(encoding="utf-8", errors="replace")
176
+ except OSError:
177
+ return "B"
178
+ if not _has_managed_markers(text):
179
+ return "B"
180
+ return "CANONICAL"
181
+
182
+ if agents_md_present:
183
+ return "D"
184
+
185
+ return "F" if not vbrief_present else "D"
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # State G -- active swarm probe
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def detect_active_swarm(project_root: Path) -> bool:
194
+ """Return True iff any ``vbrief/active/*.vbrief.json`` has ``plan.status == "running"``."""
195
+ return bool(active_swarm_paths(project_root))
196
+
197
+
198
+ def active_swarm_paths(project_root: Path) -> list[str]:
199
+ """Return the list of running-status active vBRIEF paths (project-relative)."""
200
+ active_dir = project_root / "vbrief" / "active"
201
+ if not active_dir.is_dir():
202
+ return []
203
+ paths: list[str] = []
204
+ for candidate in sorted(active_dir.glob("*.vbrief.json")):
205
+ try:
206
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
207
+ except (OSError, json.JSONDecodeError):
208
+ continue
209
+ plan = payload.get("plan") if isinstance(payload, dict) else None
210
+ if not isinstance(plan, dict):
211
+ continue
212
+ if plan.get("status") == "running":
213
+ try:
214
+ rel = candidate.relative_to(project_root)
215
+ except ValueError:
216
+ rel = candidate
217
+ paths.append(rel.as_posix())
218
+ return paths
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # State E -- customization detection
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ def _sha256_of(path: Path) -> str:
227
+ h = hashlib.sha256()
228
+ with path.open("rb") as fh:
229
+ for chunk in iter(lambda: fh.read(65_536), b""):
230
+ h.update(chunk)
231
+ return h.hexdigest()
232
+
233
+
234
+ def is_framework_customized(framework_dir: Path, framework_source: Path) -> bool:
235
+ """Return True iff any file under ``framework_dir`` differs from ``framework_source``.
236
+
237
+ Two signals, in order:
238
+
239
+ 1. Sentinel-marker fast-path -- ``framework_dir/.deft-customized``.
240
+ Test fixtures use this for deterministic state-E construction.
241
+ 2. SHA-256 hash compare against the matching file in
242
+ ``framework_source``. A file present only in ``framework_dir`` (an
243
+ extra) also counts as customization.
244
+
245
+ The reverse direction (files in source but absent in framework_dir)
246
+ is NOT customization -- it just means the consumer is behind, which
247
+ is the relocator's whole job.
248
+ """
249
+ return bool(customization_paths(framework_dir, framework_source))
250
+
251
+
252
+ def customization_paths(framework_dir: Path, framework_source: Path) -> list[str]:
253
+ """Return the project-relative paths that differ from ``framework_source``."""
254
+ if not framework_dir.is_dir():
255
+ return []
256
+
257
+ sentinel = framework_dir / ".deft-customized"
258
+ paths: list[str] = []
259
+ if sentinel.is_file():
260
+ try:
261
+ paths.append(sentinel.relative_to(framework_dir.parent).as_posix())
262
+ except ValueError:
263
+ paths.append(sentinel.name)
264
+
265
+ if not framework_source.is_dir():
266
+ return paths
267
+
268
+ for src_path in iter_files(framework_dir):
269
+ try:
270
+ rel = src_path.relative_to(framework_dir)
271
+ except ValueError:
272
+ continue
273
+ if rel.as_posix() == ".deft-customized":
274
+ continue
275
+ canonical = framework_source / rel
276
+ try:
277
+ project_rel = src_path.relative_to(framework_dir.parent).as_posix()
278
+ except ValueError:
279
+ project_rel = rel.as_posix()
280
+ if not canonical.is_file():
281
+ paths.append(project_rel)
282
+ continue
283
+ try:
284
+ if _sha256_of(src_path) != _sha256_of(canonical):
285
+ paths.append(project_rel)
286
+ except OSError:
287
+ paths.append(project_rel)
288
+ return paths
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Advisory grep -- find legacy `deft/run` hardcodes outside .deft/core/
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def advise_external_hardcodes(
297
+ project_root: Path,
298
+ *,
299
+ token: str = ADVISORY_LEGACY_TOKEN,
300
+ ) -> list[tuple[str, int, str]]:
301
+ """Return the ``(rel_path, line_number, line_text)`` tuples for legacy hardcodes.
302
+
303
+ Skips ``.deft/``, ``.deft-cache/``, ``deft/`` (the legacy framework
304
+ dir, which the relocator may not have wiped yet on a dry-run), plus
305
+ standard development noise. Inspects only the curated set of text
306
+ extensions to keep the walk fast on large consumer repos.
307
+ """
308
+ hits: list[tuple[str, int, str]] = []
309
+ for path in _iter_consumer_text_files(project_root):
310
+ try:
311
+ rel = path.relative_to(project_root).as_posix()
312
+ except ValueError:
313
+ continue
314
+ try:
315
+ stat = path.stat()
316
+ except OSError:
317
+ continue
318
+ if stat.st_size > _ADVISORY_GREP_MAX_BYTES:
319
+ continue
320
+ try:
321
+ text = path.read_text(encoding="utf-8", errors="replace")
322
+ except (OSError, UnicodeDecodeError):
323
+ continue
324
+ if token not in text:
325
+ continue
326
+ for idx, line in enumerate(text.splitlines(), start=1):
327
+ if token in line:
328
+ hits.append((rel, idx, line.rstrip()))
329
+ return hits
330
+
331
+
332
+ def _iter_consumer_text_files(project_root: Path) -> Iterator[Path]:
333
+ for path in iter_files(project_root):
334
+ try:
335
+ rel = path.relative_to(project_root)
336
+ except ValueError:
337
+ continue
338
+ first = rel.parts[0] if rel.parts else ""
339
+ if first in ADVISORY_GREP_SKIP_DIRS:
340
+ continue
341
+ if path.suffix.lower() not in ADVISORY_GREP_EXTENSIONS:
342
+ continue
343
+ yield path
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """_resolve_preflight_path.py -- resolve preflight_implementation.py
3
+ (#1046 PR-C AC-6 / absorbs #1047).
4
+
5
+ Probes for the wrapped ``preflight_implementation.py`` script in three
6
+ canonical locations under the supplied project root and prints the
7
+ resolved absolute path on stdout, OR exits non-zero with a structured
8
+ fail-closed error message that names the failure class and points the
9
+ operator at ``task framework:doctor`` (the PR-B install-integrity probe
10
+ landed in #1057).
11
+
12
+ Why this helper exists
13
+ ----------------------
14
+
15
+ The Implementation Intent Gate (#810) is a safety gate, not a routing
16
+ gate. Its failure mode is silent fail-open: if the Taskfile target
17
+ ``tasks/vbrief.yml::preflight`` wraps a hardcoded path that does not
18
+ resolve on the consumer's install layout, ``task vbrief:preflight``
19
+ either errors loudly (and the agent treats the gate as unreachable and
20
+ routes around it) or accidentally returns exit 0 (the gate emits
21
+ "preflight passed" reasoning without ever evaluating the vBRIEF). Per
22
+ issue #1047 the agent-side contract says #810 is in force on every
23
+ ``.deft/core/`` install, but the gate was structurally unreachable on
24
+ those installs.
25
+
26
+ Three layouts must resolve correctly:
27
+
28
+ 1. ``<project-root>/.deft/core/scripts/preflight_implementation.py``
29
+ -- the v0.27+ canonical install layout (#992).
30
+ 2. ``<project-root>/deft/scripts/preflight_implementation.py`` --
31
+ the legacy v0.20-v0.26 install layout.
32
+ 3. ``<project-root>/scripts/preflight_implementation.py`` -- the
33
+ in-repo case when the deft framework itself is the project root.
34
+
35
+ The resolver tries them in that order and returns the first match.
36
+ When none match the resolver exits 2 with the structured error
37
+ ``gate misconfigured: cannot resolve preflight_implementation.py at
38
+ any expected path -- run `task framework:doctor` for diagnostics`` so
39
+ the wrapping Taskfile target propagates the non-zero exit instead of
40
+ silently invoking ``uv run python <missing>`` and letting the gate's
41
+ failure shape leak through to the agent.
42
+
43
+ Mirrors the shape of ``scripts/resolve_version.py`` (#723): pure
44
+ stdlib, ``main(argv) -> int``, a public Python API
45
+ (``resolve_preflight_path(project_root)``) for tests and future
46
+ callers, and a CLI for the Taskfile body.
47
+
48
+ Refs:
49
+ - #1046 (cohort)
50
+ - #1047 (absorbed by PR-C)
51
+ - #810 (the gate this resolver wraps)
52
+ - #992 (the install layout flip that made the legacy hardcoded path stale)
53
+ - #1054 (PR-A canonical-path enforcement)
54
+ - #1057 (PR-B framework:doctor + manifest + v3 sentinel)
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import sys
61
+ from pathlib import Path
62
+
63
+ #: Candidate subpaths probed under the supplied project root, in
64
+ #: priority order. The first existing file wins. Each entry is a tuple
65
+ #: of path parts so :func:`pathlib.Path.joinpath` reconstructs the
66
+ #: location with native separators on every platform.
67
+ CANDIDATE_SUBPATHS: tuple[tuple[str, ...], ...] = (
68
+ (".deft", "core", "scripts", "preflight_implementation.py"),
69
+ ("deft", "scripts", "preflight_implementation.py"),
70
+ ("scripts", "preflight_implementation.py"),
71
+ )
72
+
73
+ #: Structured fail-closed error message. Names the failure class
74
+ #: (``gate misconfigured``) so operators (and downstream parsers) can
75
+ #: classify the exit without parsing free-form text, enumerates the
76
+ #: probed layouts so a misconfigured install surfaces the expected
77
+ #: locations, and points at ``task framework:doctor`` (the PR-B
78
+ #: install-integrity probe from #1057) for the diagnostic surface.
79
+ FAIL_CLOSED_MESSAGE = (
80
+ "gate misconfigured: cannot resolve preflight_implementation.py "
81
+ "at any expected path (.deft/core/scripts/, deft/scripts/, scripts/) "
82
+ "under project root {project_root} -- "
83
+ "run `task framework:doctor` for diagnostics."
84
+ )
85
+
86
+
87
+ def resolve_preflight_path(project_root: Path | str) -> Path | None:
88
+ """Probe the candidate subpaths under ``project_root``.
89
+
90
+ Returns the absolute resolved path of the first existing
91
+ ``preflight_implementation.py``, or ``None`` if no candidate
92
+ resolves. Pure function -- no I/O beyond ``Path.is_file()``.
93
+
94
+ The project root is resolved to an absolute path first so callers
95
+ that pass a relative path (e.g. ``"."`` from a Taskfile target
96
+ invoked under ``USER_WORKING_DIR``) get a stable absolute result.
97
+ """
98
+ root = Path(project_root).resolve()
99
+ for parts in CANDIDATE_SUBPATHS:
100
+ candidate = root.joinpath(*parts)
101
+ if candidate.is_file():
102
+ return candidate
103
+ return None
104
+
105
+
106
+ def _build_parser() -> argparse.ArgumentParser:
107
+ parser = argparse.ArgumentParser(
108
+ prog="_resolve_preflight_path.py",
109
+ description=(
110
+ "Resolve preflight_implementation.py under the supplied "
111
+ "project root (#1046 PR-C / #1047). Probes the v0.27+ "
112
+ "canonical install layout (.deft/core/scripts/), the "
113
+ "legacy install layout (deft/scripts/), and the in-repo "
114
+ "case (scripts/) in that order. Prints the resolved "
115
+ "absolute path on stdout, or exits 2 with a structured "
116
+ "fail-closed error message naming the failure class and "
117
+ "pointing at `task framework:doctor`."
118
+ ),
119
+ )
120
+ parser.add_argument(
121
+ "--project-root",
122
+ default=".",
123
+ help=(
124
+ "Project root to probe. Defaults to the current working "
125
+ "directory so `task vbrief:preflight` can pass "
126
+ "{{.USER_WORKING_DIR}} through unchanged."
127
+ ),
128
+ )
129
+ return parser
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> int:
133
+ parser = _build_parser()
134
+ args = parser.parse_args(argv)
135
+ project_root = Path(args.project_root)
136
+ resolved = resolve_preflight_path(project_root)
137
+ if resolved is None:
138
+ print(
139
+ FAIL_CLOSED_MESSAGE.format(project_root=project_root.resolve()),
140
+ file=sys.stderr,
141
+ )
142
+ return 2
143
+ # Print the resolved path on stdout WITHOUT a trailing newline so
144
+ # the Taskfile body can capture it via $(...) without trimming
145
+ # whitespace -- matches the convention from scripts/resolve_version.py
146
+ # which also uses raw stdout writes for the same reason.
147
+ sys.stdout.write(str(resolved))
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,167 @@
1
+ """_safe_subprocess.py -- UTF-8-safe subprocess capture helper (#1366).
2
+
3
+ Wraps :func:`subprocess.run` with the defaults required for reliable text
4
+ capture under Windows hosts where the system codepage (cp1252 / cp437)
5
+ otherwise corrupts non-ASCII bytes emitted by ``gh`` / Greptile rolling
6
+ summaries and crashes one of Python's internal reader threads with
7
+ ``UnicodeDecodeError`` (the canonical ``Thread-3 (_readerthread)`` stack
8
+ seen across the #1166 swarm session).
9
+
10
+ Background
11
+ ----------
12
+ The default ``subprocess.run(..., capture_output=True, text=True)`` binding
13
+ uses ``locale.getpreferredencoding()`` to decode the child process's
14
+ stdout / stderr streams. On Windows + Grok Build that resolves to the
15
+ active codepage rather than UTF-8, so any byte the codepage cannot decode
16
+ raises ``UnicodeDecodeError`` from inside the helper thread that drains
17
+ the pipe. Once that thread crashes, the calling script returns no valid
18
+ output on stdout (or crashes outright), and any dependent monitor that
19
+ parses the JSON sees ``head: None`` / empty data.
20
+
21
+ The fix is to force ``encoding="utf-8"`` and ``errors="replace"`` on every
22
+ text-capturing subprocess call. ``replace`` substitutes the U+FFFD
23
+ replacement character for any undecodable byte rather than raising; the
24
+ parser downstream then sees a well-formed string with at most a handful
25
+ of replacement glyphs in the otherwise-clean Greptile body.
26
+
27
+ Usage
28
+ -----
29
+
30
+ from _safe_subprocess import run_text
31
+
32
+ result = run_text(["gh", "api", "repos/<owner>/<repo>/pulls/<N>"])
33
+ if result.returncode == 0:
34
+ body = result.stdout
35
+
36
+ Scope
37
+ -----
38
+ This helper covers the read-side text capture path that the #1366 root
39
+ cause analysis identified. It is NOT a general-purpose ``subprocess.run``
40
+ replacement -- callers that need binary streams (``capture_output=True``
41
+ with ``text=False``) or process redirection should keep using
42
+ ``subprocess.run`` directly. The helper deliberately rejects ``shell=True``
43
+ to keep injection-prone usage out of the framework's surface (per
44
+ ``coding/security.md`` Input Validation rules).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import subprocess
50
+ from collections.abc import Mapping, Sequence
51
+ from typing import Any
52
+
53
+ # Default timeout (seconds) when callers do not specify one. Mirrors the
54
+ # 60s ceiling used by ``scripts/pr_merge_readiness.py::_run_gh`` so the
55
+ # helper does not silently relax existing call-site timeouts.
56
+ _DEFAULT_TIMEOUT_SECONDS = 60
57
+
58
+
59
+ def run_text( # noqa: A002 -- `input` parameter name mirrors subprocess.run
60
+ cmd: Sequence[str],
61
+ *,
62
+ timeout: float | None = _DEFAULT_TIMEOUT_SECONDS,
63
+ input: str | None = None, # noqa: A002
64
+ cwd: str | None = None,
65
+ env: Mapping[str, str] | None = None,
66
+ check: bool = False,
67
+ **extra: Any,
68
+ ) -> subprocess.CompletedProcess[str]:
69
+ """Run ``cmd`` capturing stdout / stderr as UTF-8 text safely.
70
+
71
+ Equivalent to::
72
+
73
+ subprocess.run(
74
+ cmd,
75
+ capture_output=True,
76
+ text=True,
77
+ encoding="utf-8",
78
+ errors="replace",
79
+ ...,
80
+ )
81
+
82
+ with the following guarantees:
83
+
84
+ - ``encoding="utf-8"`` and ``errors="replace"`` are FORCED -- callers
85
+ cannot override them via ``**extra``. Any attempt is ignored so a
86
+ typo cannot reintroduce the cp1252 decode bug.
87
+ - ``capture_output=True`` is FORCED -- callers cannot accidentally
88
+ pass ``stdout=None`` / ``stderr=None`` and lose the captured streams.
89
+ - ``shell=False`` is FORCED -- callers cannot opt into shell expansion
90
+ via ``**extra`` (mirrors ``coding/security.md`` "no shell=True on
91
+ untrusted input").
92
+ - ``timeout`` defaults to 60s. Pass ``timeout=None`` explicitly to
93
+ disable; pass an explicit value to override. ``subprocess.run``
94
+ raises :class:`subprocess.TimeoutExpired` on overrun -- callers
95
+ handle that the same way they would with the bare API.
96
+
97
+ The returned :class:`subprocess.CompletedProcess` exposes ``returncode``,
98
+ ``stdout``, and ``stderr`` exactly as ``subprocess.run`` would. The
99
+ ``check=False`` default mirrors the bare API; pass ``check=True`` to
100
+ raise :class:`subprocess.CalledProcessError` on non-zero exit.
101
+
102
+ Args:
103
+ cmd: Argument vector for the child process. MUST be a sequence
104
+ (list / tuple) -- the helper rejects ``str`` to discourage
105
+ shell-quoting bugs (mirrors ``subprocess.run``'s
106
+ ``shell=False`` requirement).
107
+ timeout: Seconds to wait for the child to exit. Defaults to 60.
108
+ Pass ``None`` to wait indefinitely.
109
+ input: Optional UTF-8 text to feed into the child's stdin.
110
+ cwd: Optional working directory for the child.
111
+ env: Optional environment mapping. ``None`` inherits the parent's
112
+ env (the default :func:`subprocess.run` behavior).
113
+ check: If ``True``, raise :class:`subprocess.CalledProcessError`
114
+ on non-zero exit (mirrors :func:`subprocess.run`).
115
+ **extra: Forwarded to :func:`subprocess.run`. Keys that would
116
+ conflict with the forced safety defaults (``capture_output``,
117
+ ``text``, ``encoding``, ``errors``, ``shell``, ``stdout``,
118
+ ``stderr``) are silently dropped.
119
+
120
+ Returns:
121
+ :class:`subprocess.CompletedProcess` with ``stdout`` and ``stderr``
122
+ as UTF-8 strings (any undecodable bytes replaced with U+FFFD).
123
+
124
+ Raises:
125
+ subprocess.TimeoutExpired: If the child does not exit within
126
+ ``timeout`` seconds.
127
+ subprocess.CalledProcessError: If ``check=True`` and the child
128
+ exits non-zero.
129
+ FileNotFoundError: If the executable cannot be found.
130
+ TypeError: If ``cmd`` is a bare string (callers should pass a
131
+ sequence so argv quoting is unambiguous).
132
+ """
133
+ if isinstance(cmd, (str, bytes)):
134
+ raise TypeError(
135
+ "run_text requires a sequence of arguments (e.g. ['gh', 'api', ...]); "
136
+ "passing a single string would require shell=True which is forbidden."
137
+ )
138
+
139
+ # Drop any caller-provided keys that conflict with the forced safety
140
+ # defaults. Silently ignoring beats raising because most callers are
141
+ # mechanically refactoring existing subprocess.run sites that may
142
+ # have redundant text=True / encoding=... kwargs.
143
+ forbidden_keys = {
144
+ "capture_output",
145
+ "text",
146
+ "encoding",
147
+ "errors",
148
+ "shell",
149
+ "stdout",
150
+ "stderr",
151
+ }
152
+ sanitized = {k: v for k, v in extra.items() if k not in forbidden_keys}
153
+
154
+ return subprocess.run(
155
+ list(cmd),
156
+ capture_output=True,
157
+ text=True,
158
+ encoding="utf-8",
159
+ errors="replace",
160
+ shell=False,
161
+ timeout=timeout,
162
+ input=input,
163
+ cwd=cwd,
164
+ env=dict(env) if env is not None else None,
165
+ check=check,
166
+ **sanitized,
167
+ )