@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,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
|
+
)
|