@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. 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())