@deftai/directive-content 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,266 +0,0 @@
1
- #!/usr/bin/env python3
2
- """``task policy:show`` argparse shim (#1148 / N8 of #1119 Wave-2d-1).
3
-
4
- Extracted from :mod:`scripts.policy` so the parent module stays well
5
- under the 1000-line MUST cap from ``coding/coding.md``. The CLI delegates
6
- to :func:`policy.inspect_all_policies` / :func:`policy.inspect_one_policy`
7
- for every read; the render layer here is purely cosmetic.
8
-
9
- Flags (mirror the #1148 issue body):
10
-
11
- * ``--format=text|json`` -- ``text`` is the default human form (one block
12
- per field); ``json`` emits the stable schema
13
- ``{generated_at, fields: [{name, current, default, source}, ...]}``.
14
- * ``--changed-only`` -- drop rows whose source is ``default`` so the
15
- output focuses on what the operator actually configured. Combines
16
- cleanly with ``--format=json`` for ``jq`` consumption.
17
- * ``--field=<canonical-dotted-path>`` -- show exactly one registered
18
- field; exit 2 with the recognised-names list when ``<name>`` is not
19
- a registered field. Mutually compatible with ``--format=json``.
20
- * ``--project-root <path>`` -- override the project root (defaults to
21
- ``Path.cwd()``); useful for tests + tools dispatching from outside
22
- the consumer working directory.
23
-
24
- Exit codes:
25
-
26
- * ``0`` -- success (including the "all defaults" + "missing
27
- PROJECT-DEFINITION" cases; the verb is informational).
28
- * ``2`` -- argparse usage error OR unknown ``--field=<name>``.
29
-
30
- Pure-stdlib; runs anywhere :mod:`scripts.policy` does.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import argparse
36
- import contextlib
37
- import json
38
- import sys
39
- from datetime import UTC, datetime
40
- from pathlib import Path
41
- from typing import Any
42
-
43
- # Make sibling scripts importable when invoked as
44
- # ``python scripts/_policy_show_cli.py`` (the dispatch shape go-task uses).
45
- sys.path.insert(0, str(Path(__file__).resolve().parent))
46
-
47
- # UTF-8 self-reconfigure -- the rendered values for ``triageScope`` etc.
48
- # can include non-ASCII characters that crash cp1252 on Windows.
49
- for _stream in (sys.stdout, sys.stderr):
50
- if hasattr(_stream, "reconfigure"):
51
- with contextlib.suppress(AttributeError, ValueError):
52
- _stream.reconfigure(encoding="utf-8", errors="replace")
53
-
54
- import policy # noqa: E402 (sibling import after sys.path tweak)
55
-
56
- # ---------------------------------------------------------------------------
57
- # Public helpers (test-injectable)
58
- # ---------------------------------------------------------------------------
59
-
60
-
61
- def _utc_iso(dt: datetime | None = None) -> str:
62
- """ISO-8601 UTC timestamp with seconds precision and ``Z`` suffix."""
63
- return (dt or datetime.now(UTC)).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
64
-
65
-
66
- def field_to_dict(field: policy.PolicyField) -> dict[str, Any]:
67
- """Render one :class:`policy.PolicyField` as a JSON-stable dict.
68
-
69
- Stable key order: ``name``, ``current``, ``default``, ``source``.
70
- Stable across releases -- the JSON schema is the scripting contract.
71
- """
72
- return {
73
- "name": field.name,
74
- "current": field.current,
75
- "default": field.default,
76
- "source": field.source,
77
- }
78
-
79
-
80
- def render_json(
81
- fields: list[policy.PolicyField],
82
- *,
83
- now: datetime | None = None,
84
- ) -> str:
85
- """Render the JSON envelope ``{generated_at, fields: [...]}``.
86
-
87
- ``ensure_ascii=False`` so non-ASCII operator values (rare but
88
- possible -- e.g. milestone names with em dashes) survive the
89
- serialisation round-trip without ``\\uXXXX`` escaping.
90
- """
91
- envelope = {
92
- "generated_at": _utc_iso(now),
93
- "fields": [field_to_dict(f) for f in fields],
94
- }
95
- return json.dumps(envelope, ensure_ascii=False, indent=2, sort_keys=False)
96
-
97
-
98
- def _format_value(value: Any) -> str:
99
- """Render a value for the text format.
100
-
101
- Booleans render as ``true`` / ``false`` (the issue-body example output
102
- used lowercase JSON-style booleans). Lists and dicts round-trip
103
- through ``json.dumps`` for a stable, copy-pasteable shape. Strings
104
- render verbatim; numbers via ``repr`` so floats keep precision.
105
- """
106
- if isinstance(value, bool):
107
- return "true" if value else "false"
108
- if isinstance(value, (list, dict)):
109
- return json.dumps(value, ensure_ascii=False, sort_keys=False)
110
- if isinstance(value, str):
111
- return value
112
- return repr(value)
113
-
114
-
115
- def render_text(fields: list[policy.PolicyField]) -> str:
116
- """Render the human-readable text format from the issue body.
117
-
118
- Each field renders as a four-line block:
119
-
120
- .. code-block:: text
121
-
122
- [policy] <name>
123
- current: <value>
124
- default: <value>
125
- source: <typed|default|legacy>
126
-
127
- Blocks are separated by a blank line. An empty ``fields`` list
128
- (``--changed-only`` against an all-defaults config) renders a single
129
- informational line so the operator does not see a blank screen.
130
- """
131
- if not fields:
132
- return (
133
- "[policy] (no fields changed)\n"
134
- " All registered policies are at their framework defaults. "
135
- "Re-run without `--changed-only` to inspect them."
136
- )
137
- blocks: list[str] = []
138
- for field in fields:
139
- block = (
140
- f"[policy] {field.name}\n"
141
- f" current: {_format_value(field.current)}\n"
142
- f" default: {_format_value(field.default)}\n"
143
- f" source: {field.source}"
144
- )
145
- blocks.append(block)
146
- return "\n\n".join(blocks)
147
-
148
-
149
- # ---------------------------------------------------------------------------
150
- # argparse setup
151
- # ---------------------------------------------------------------------------
152
-
153
-
154
- def _build_parser() -> argparse.ArgumentParser:
155
- parser = argparse.ArgumentParser(
156
- prog="task policy:show",
157
- description=(
158
- "Inspect every registered typed-policy field on "
159
- "vbrief/PROJECT-DEFINITION.vbrief.json (#1148 / N8 of #1119)."
160
- ),
161
- )
162
- parser.add_argument(
163
- "--format",
164
- choices=("text", "json"),
165
- default="text",
166
- help="Output format (default: text). Use json for stable scripting schema.",
167
- )
168
- parser.add_argument(
169
- "--changed-only",
170
- action="store_true",
171
- help=(
172
- "Filter out fields whose source is 'default'. "
173
- "Keeps 'typed' and 'legacy' rows only."
174
- ),
175
- )
176
- parser.add_argument(
177
- "--field",
178
- dest="field",
179
- metavar="<name>",
180
- default=None,
181
- help=(
182
- "Show exactly one registered field by canonical dotted path "
183
- "(e.g. plan.policy.wipCap). Exits 2 on unknown name."
184
- ),
185
- )
186
- parser.add_argument(
187
- "--project-root",
188
- dest="project_root",
189
- metavar="<path>",
190
- default=None,
191
- help="Project root (default: current working directory).",
192
- )
193
- return parser
194
-
195
-
196
- def run_cli(argv: list[str] | None = None) -> int:
197
- """Argparse + dispatch for ``task policy:show``.
198
-
199
- Exit 0 in every success path (including all-defaults and missing
200
- PROJECT-DEFINITION). Exit 2 for argparse usage errors and for
201
- ``--field=<name>`` where ``<name>`` is not a registered field.
202
- """
203
- parser = _build_parser()
204
- try:
205
- args = parser.parse_args(argv)
206
- except SystemExit as exc:
207
- # argparse already emitted its error to stderr; preserve its code.
208
- return int(exc.code) if isinstance(exc.code, int) else 2
209
-
210
- project_root = (
211
- Path(args.project_root).resolve() if args.project_root else Path.cwd()
212
- )
213
-
214
- # Surface a friendly informational line on missing PROJECT-DEFINITION so
215
- # the operator understands why every row will be at default. Exit 0;
216
- # show is informational by contract.
217
- pd_path = project_root / policy.PROJECT_DEFINITION_REL_PATH
218
- if not pd_path.is_file():
219
- sys.stderr.write(
220
- f"[policy:show] PROJECT-DEFINITION not found at {pd_path}; "
221
- "rendering framework defaults.\n"
222
- )
223
-
224
- if args.field is not None:
225
- return _dispatch_single_field(args, project_root)
226
- return _dispatch_all_fields(args, project_root)
227
-
228
-
229
- def _dispatch_single_field(args: argparse.Namespace, project_root: Path) -> int:
230
- field = policy.inspect_one_policy(args.field, project_root)
231
- if field is None:
232
- known = policy.registered_policy_names()
233
- sys.stderr.write(
234
- f"[policy:show] unknown --field={args.field!r}; "
235
- f"registered fields: {known}\n"
236
- )
237
- return 2
238
- # ``--changed-only`` against a single-field default is a no-op render --
239
- # operators asking for a single field by name almost always want to see
240
- # it regardless of source. The default branch keeps the row; the
241
- # ``--changed-only`` filter only fires across the all-fields surface.
242
- if args.format == "json":
243
- sys.stdout.write(render_json([field]) + "\n")
244
- else:
245
- sys.stdout.write(render_text([field]) + "\n")
246
- return 0
247
-
248
-
249
- def _dispatch_all_fields(args: argparse.Namespace, project_root: Path) -> int:
250
- fields = policy.inspect_all_policies(project_root)
251
- if args.changed_only:
252
- fields = [f for f in fields if f.source != "default"]
253
- if args.format == "json":
254
- sys.stdout.write(render_json(fields) + "\n")
255
- else:
256
- sys.stdout.write(render_text(fields) + "\n")
257
- return 0
258
-
259
-
260
- def main(argv: list[str] | None = None) -> int:
261
- """CLI entry point. Alias for :func:`run_cli` so tests can patch it."""
262
- return run_cli(argv)
263
-
264
-
265
- if __name__ == "__main__":
266
- sys.exit(main())
@@ -1,92 +0,0 @@
1
- """Shared pre-v0.20 document-model detection helpers.
2
-
3
- The session-start guard, CLI gate, validator, and migration preflight all need
4
- the same distinction:
5
-
6
- * deprecation redirect stubs are migrated/current enough;
7
- * generated ``SPECIFICATION.md`` exports from ``task spec:render`` are not
8
- hand-authored legacy docs when their source JSON exists, and are fully
9
- current vBRIEF artifacts when their lifecycle folders also exist;
10
- * hand-authored root docs are legacy pre-cutover inputs.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from pathlib import Path
16
-
17
- LIFECYCLE_FOLDERS: tuple[str, ...] = (
18
- "proposed",
19
- "pending",
20
- "active",
21
- "completed",
22
- "cancelled",
23
- )
24
-
25
- DEPRECATED_REDIRECT_SENTINEL = "<!-- deft:deprecated-redirect -->"
26
- DEPRECATION_REDIRECT_PURPOSE = "<!-- Purpose: deprecation redirect -->"
27
-
28
- GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->"
29
- GENERATED_SPEC_SOURCE = "<!-- Source of truth: vbrief/specification.vbrief.json -->"
30
- SPEC_SOURCE_RELPATH = Path("vbrief") / "specification.vbrief.json"
31
-
32
-
33
- def missing_lifecycle_folders(project_root: Path) -> list[str]:
34
- """Return missing vBRIEF lifecycle folder names for ``project_root``."""
35
- vbrief_root = project_root / "vbrief"
36
- return [folder for folder in LIFECYCLE_FOLDERS if not (vbrief_root / folder).is_dir()]
37
-
38
-
39
- def has_complete_lifecycle(project_root: Path) -> bool:
40
- """Return True when every canonical lifecycle folder exists."""
41
- return not missing_lifecycle_folders(project_root)
42
-
43
-
44
- def is_deprecation_redirect(content: str) -> bool:
45
- """Return True when markdown content is a migration redirect stub."""
46
- return DEPRECATED_REDIRECT_SENTINEL in content or DEPRECATION_REDIRECT_PURPOSE in content
47
-
48
-
49
- def is_generated_specification_export(project_root: Path, content: str) -> bool:
50
- """Return True for a generated ``task spec:render`` root export.
51
-
52
- The banner alone is not enough: the declared vBRIEF source must also
53
- exist. Lifecycle completeness is checked separately by
54
- ``is_current_generated_specification``.
55
- """
56
- return (
57
- GENERATED_SPEC_PURPOSE in content
58
- and GENERATED_SPEC_SOURCE in content
59
- and (project_root / SPEC_SOURCE_RELPATH).is_file()
60
- )
61
-
62
-
63
- def is_current_generated_specification(project_root: Path, content: str) -> bool:
64
- """Return True for a fully current ``task spec:render`` root export."""
65
- return is_generated_specification_export(project_root, content) and has_complete_lifecycle(
66
- project_root
67
- )
68
-
69
-
70
- def root_markdown_is_legacy(project_root: Path, filename: str, content: str) -> bool:
71
- """Return True if a root markdown artifact should trigger migration."""
72
- if is_deprecation_redirect(content):
73
- return False
74
- if filename == "SPECIFICATION.md" and is_generated_specification_export(project_root, content):
75
- return False
76
- return filename in {"SPECIFICATION.md", "PROJECT.md"}
77
-
78
-
79
- def detect_pre_cutover_legacy(project_root: Path) -> list[str]:
80
- """Return root artifact filenames that are legacy pre-v0.20 inputs."""
81
- legacy: list[str] = []
82
- for filename in ("SPECIFICATION.md", "PROJECT.md"):
83
- candidate = project_root / filename
84
- if not candidate.is_file():
85
- continue
86
- try:
87
- content = candidate.read_text(encoding="utf-8", errors="replace")
88
- except OSError:
89
- continue
90
- if root_markdown_is_legacy(project_root, filename, content):
91
- legacy.append(filename)
92
- return legacy
@@ -1,224 +0,0 @@
1
- """_project_context.py -- resolve consumer project root + GitHub repo slug.
2
-
3
- Shared helpers used by ``scope_lifecycle``, ``issue_ingest``,
4
- ``reconcile_issues`` and ``prd_render`` so every script follows the same
5
- precedence rules and fails loudly when no project context can be inferred.
6
-
7
- Precedence for ``resolve_project_root``:
8
-
9
- 1. ``--project-root`` flag (explicit, highest precedence).
10
- 2. ``$DEFT_PROJECT_ROOT`` environment variable.
11
- 3. Walk upward from CWD looking for a ``vbrief/`` directory or a ``.git``
12
- directory -- the first match is the project root.
13
- 4. Fall back to the current working directory ONLY if it visibly looks
14
- like a project root (contains either ``vbrief/`` or ``.git``).
15
-
16
- If none of those match, the caller gets ``None`` and is expected to emit
17
- a loud, actionable error -- silently falling back to ``deft/`` is exactly
18
- the bug that shipped #535 / #538.
19
-
20
- Precedence for ``resolve_project_repo``:
21
-
22
- 1. ``--repo OWNER/NAME`` flag (explicit, highest precedence).
23
- 2. ``$DEFT_PROJECT_REPO`` environment variable.
24
- 3. ``git remote get-url origin`` run from the resolved project root --
25
- this is the key anti-regression for #538: deft's own ``.git`` remote
26
- (``deftai/directive``) is used only if the project root happens to be
27
- deft itself.
28
- 4. ``None`` -- caller must emit a loud error.
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- import argparse
34
- import os
35
- import re
36
- import subprocess
37
- from collections.abc import Callable
38
- from pathlib import Path
39
-
40
- from framework_commands import CommandResult, run_framework_command
41
-
42
- # Sentinel directories that mark a deft project root.
43
- _PROJECT_ROOT_SENTINELS = ("vbrief", ".git")
44
-
45
-
46
- def _is_project_root(candidate: Path) -> bool:
47
- """Return True if *candidate* contains any deft project-root sentinel."""
48
- return any((candidate / sentinel).exists() for sentinel in _PROJECT_ROOT_SENTINELS)
49
-
50
-
51
- def resolve_project_root(
52
- cli_project_root: str | None = None,
53
- *,
54
- start: Path | None = None,
55
- ) -> Path | None:
56
- """Resolve the consumer project root using the documented precedence.
57
-
58
- Returns ``None`` when no candidate matches; callers MUST fail loudly
59
- in that case (never silently fall back to deft's own directory).
60
- """
61
- if cli_project_root:
62
- candidate = Path(cli_project_root).resolve()
63
- if candidate.is_dir():
64
- return candidate
65
- return None
66
-
67
- env_root = os.environ.get("DEFT_PROJECT_ROOT")
68
- if env_root:
69
- candidate = Path(env_root).resolve()
70
- if candidate.is_dir():
71
- return candidate
72
- return None
73
-
74
- cwd = (start or Path.cwd()).resolve()
75
- # Walk upward from CWD looking for a sentinel.
76
- for candidate in (cwd, *cwd.parents):
77
- if _is_project_root(candidate):
78
- return candidate
79
- return None
80
-
81
-
82
- def resolve_project_repo(
83
- cli_repo: str | None = None,
84
- *,
85
- project_root: Path | None = None,
86
- ) -> str | None:
87
- """Resolve the consumer GitHub repo (``OWNER/NAME``).
88
-
89
- Returns ``None`` when detection fails so the caller can emit an
90
- actionable error. ``project_root`` narrows ``git remote`` detection to
91
- the consumer repo; without it we fall back to CWD, which may be wrong
92
- under a ``task deft:*`` include (#538).
93
- """
94
- if cli_repo:
95
- slug = _normalise_repo_slug(cli_repo)
96
- if slug:
97
- return slug
98
- return None
99
-
100
- env_repo = os.environ.get("DEFT_PROJECT_REPO")
101
- if env_repo:
102
- slug = _normalise_repo_slug(env_repo)
103
- if slug:
104
- return slug
105
- # Greptile P2 on #562: fail loudly when the env var is set but
106
- # unparseable, to match the explicit-flag path (which returns
107
- # None on a malformed value rather than falling through to git
108
- # auto-detection). Silent fallback to git is exactly the
109
- # anti-pattern this helper was introduced to prevent.
110
- return None
111
-
112
- return _detect_repo_from_git(project_root)
113
-
114
-
115
- def _normalise_repo_slug(value: str) -> str | None:
116
- r"""Accept ``OWNER/NAME`` or a full GitHub URL, return ``OWNER/NAME``.
117
-
118
- Allows dots in the name component (``acme/dotnet.runtime``,
119
- ``acme/my.project.git``) -- the previous ``[^/\.\s]+`` pattern stopped
120
- at the first dot and silently truncated the repo name, routing ``gh``
121
- calls to the wrong (or non-existent) repository (Greptile P1 on #562).
122
- Strips a trailing ``.git`` suffix explicitly so SSH clone URLs still
123
- normalise to the bare ``OWNER/NAME`` form.
124
- """
125
- value = value.strip()
126
- if not value:
127
- return None
128
- match = re.search(
129
- r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)",
130
- value,
131
- )
132
- if match:
133
- return f"{match.group(1)}/{match.group(2)}"
134
- if re.match(r"^[^/\s]+/[^/\s]+$", value):
135
- return value
136
- return None
137
-
138
-
139
- def _detect_repo_from_git(project_root: Path | None) -> str | None:
140
- """Run ``git remote get-url origin`` in *project_root* (or CWD)."""
141
- cwd = str(project_root) if project_root else None
142
- try:
143
- result = subprocess.run(
144
- ["git", "remote", "get-url", "origin"],
145
- capture_output=True,
146
- text=True,
147
- timeout=10,
148
- cwd=cwd,
149
- )
150
- except (FileNotFoundError, subprocess.TimeoutExpired):
151
- return None
152
- if result.returncode != 0:
153
- return None
154
- return _normalise_repo_slug(result.stdout.strip())
155
-
156
-
157
- def is_framework_source_context(framework_root: Path, project_root: Path) -> bool:
158
- """Return True when a task is running from the framework checkout itself.
159
-
160
- Vendored consumer installs execute framework tasks from ``.deft/core`` while
161
- the user working directory remains the consumer repo. Equality of the two
162
- lexical absolute roots is the stable distinction: only the source checkout
163
- should run source-repo self-tests by default. Do not resolve symlinks here:
164
- a consumer project may symlink ``.deft/core`` to a local framework checkout
165
- and should still run the consumer-safe gate.
166
- """
167
- return Path(os.path.abspath(framework_root)) == Path(os.path.abspath(project_root))
168
-
169
-
170
- def dispatch_task_check(
171
- framework_root: Path,
172
- project_root: Path,
173
- *,
174
- runner: Callable[
175
- [str, Path, Path],
176
- CommandResult | subprocess.CompletedProcess[str],
177
- ]
178
- | None = None,
179
- ) -> int:
180
- """Dispatch ``check`` to the context-appropriate aggregate target."""
181
- target = (
182
- "check:framework-source"
183
- if is_framework_source_context(framework_root, project_root)
184
- else "check:consumer"
185
- )
186
- command_runner = runner or (
187
- lambda command, root, framework: run_framework_command(
188
- command,
189
- project_root=root,
190
- framework_root=framework,
191
- )
192
- )
193
- result = command_runner(target, project_root, framework_root)
194
- code = getattr(result, "code", None)
195
- if code is None:
196
- code = result.returncode
197
- return int(code)
198
-
199
-
200
- def _build_parser() -> argparse.ArgumentParser:
201
- parser = argparse.ArgumentParser(description=__doc__)
202
- parser.add_argument(
203
- "--dispatch-task-check",
204
- action="store_true",
205
- help="Dispatch the check aggregate for the current install context.",
206
- )
207
- parser.add_argument("--framework-root", type=Path)
208
- parser.add_argument("--project-root", type=Path)
209
- return parser
210
-
211
-
212
- def main(argv: list[str] | None = None) -> int:
213
- parser = _build_parser()
214
- args = parser.parse_args(argv)
215
- if args.dispatch_task_check:
216
- if args.framework_root is None or args.project_root is None:
217
- raise SystemExit("--framework-root and --project-root are required")
218
- return dispatch_task_check(args.framework_root, args.project_root)
219
- parser.print_help()
220
- return 0
221
-
222
-
223
- if __name__ == "__main__":
224
- raise SystemExit(main())