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