@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,149 +0,0 @@
1
- #!/usr/bin/env python3
2
- """preflight_wip_cap.py -- ``task verify:wip-cap`` re-validation gate (#1124 / D4 of #1119).
3
-
4
- CI-level re-validation that the consumer's ``pending/ + active/`` count
5
- is within the typed ``plan.policy.wipCap`` (default 10 per umbrella
6
- #1119 Current Shape v3). Catches three drift scenarios that
7
- ``task scope:promote``'s own cap check cannot:
8
-
9
- 1. **Stale-branch merge** -- a PR was within cap at PR-open but master
10
- advanced past the cap before merge. ``scope:promote`` ran on the PR
11
- branch (within cap); the merged combination is over cap.
12
- 2. **Force-merge bypass** -- an operator opened a PR with ``scope:promote
13
- --force`` (audit-logged, but still merged); the merge surfaces the
14
- override on the base branch.
15
- 3. **Out-of-band edits** -- a vBRIEF was moved into ``pending/`` via
16
- filesystem operations (``git mv``, IDE drag) without going through
17
- ``scope:promote``; the cap was never enforced.
18
-
19
- Behaviour contract:
20
-
21
- * Three-state exit (mirrors ``scripts/preflight_branch.py`` / #747):
22
- ``0`` -- count within cap; ``1`` -- count >= cap (over cap); ``2`` --
23
- config error (PROJECT-DEFINITION malformed). All paths print
24
- diagnostic to stderr with the canonical relief verbs.
25
- * ``--allow-over-cap`` -- escape hatch for the framework's own
26
- ``task check`` so deft's own landing-day overage (currently
27
- ``pending/+active/`` >> 10 -- see umbrella v3 "Landing-day overage
28
- handled via D1 ``scope:demote --batch``") does not break framework
29
- self-check. Consumer projects MUST NOT pass this; their ``task check``
30
- fails loudly when over cap. Mirrors ``verify:cache-fresh``'s
31
- ``--allow-missing-bootstrap`` shape.
32
- * ``--project-root`` -- consumer project root; defaults to CWD. Mirrors
33
- the existing preflight scripts.
34
-
35
- Pure stdlib so the gate runs from a fresh git hook or minimal CI runner
36
- without ``uv sync``.
37
- """
38
-
39
- from __future__ import annotations
40
-
41
- import argparse
42
- import sys
43
- from pathlib import Path
44
-
45
- # Make sibling ``policy`` importable when run as ``__main__``.
46
- sys.path.insert(0, str(Path(__file__).resolve().parent))
47
-
48
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
49
-
50
- reconfigure_stdio()
51
-
52
-
53
- def _format_refusal(count: int, cap: int, project_root: Path) -> str:
54
- return (
55
- f"\u274c verify:wip-cap: {count}/{cap} in pending/+active/ "
56
- f"(over cap; project_root={project_root}).\n"
57
- " Drain the WIP set before merging:\n"
58
- " task scope:demote <existing> # return one to proposed/\n"
59
- " task scope:demote --batch --older-than-days 30 # bulk relief\n"
60
- " Or open a follow-up PR with --force-merge intent (audit-logged).\n"
61
- " (#1124 / D4 of #1119; see plan.policy.wipCap in "
62
- "vbrief/PROJECT-DEFINITION.vbrief.json.)"
63
- )
64
-
65
-
66
- def main(argv: list[str] | None = None) -> int:
67
- parser = argparse.ArgumentParser(
68
- prog="preflight_wip_cap.py",
69
- description=(
70
- "Re-validate plan.policy.wipCap on the base branch (#1124 / D4 of #1119). "
71
- "Catches stale-branch merges, --force overrides, and out-of-band edits."
72
- ),
73
- )
74
- parser.add_argument(
75
- "--project-root",
76
- default=".",
77
- help="Consumer project root (default: CWD).",
78
- )
79
- parser.add_argument(
80
- "--allow-over-cap",
81
- action="store_true",
82
- help=(
83
- "Print an INFO line + exit 0 even when over cap. Reserved for "
84
- "the framework's own task check during landing-day overage; "
85
- "consumer projects MUST NOT pass this."
86
- ),
87
- )
88
- parser.add_argument(
89
- "--quiet",
90
- action="store_true",
91
- help="Suppress the success / over-cap-allowed banner; refusal stays loud.",
92
- )
93
- args = parser.parse_args(argv)
94
-
95
- project_root = Path(args.project_root).resolve()
96
-
97
- # Lazy import so a partial install can still produce a sensible
98
- # error (mirrors the scope_lifecycle.py pattern).
99
- try:
100
- from policy import count_vbrief_wip, resolve_wip_cap # noqa: I001
101
- except ImportError as exc: # pragma: no cover -- D4 not present
102
- print(
103
- f"\u274c verify:wip-cap: scripts.policy not importable: {exc}",
104
- file=sys.stderr,
105
- )
106
- return 2
107
-
108
- cap_result = resolve_wip_cap(project_root)
109
- if cap_result.source == "default-on-error" and cap_result.error:
110
- print(
111
- f"\u274c verify:wip-cap: PROJECT-DEFINITION malformed: {cap_result.error}",
112
- file=sys.stderr,
113
- )
114
- return 2
115
-
116
- cap = cap_result.cap
117
- count = count_vbrief_wip(project_root)
118
-
119
- if count < cap:
120
- if not args.quiet:
121
- print(
122
- f"\u2713 verify:wip-cap: {count}/{cap} in pending/+active/ "
123
- f"(within cap; source={cap_result.source})."
124
- )
125
- return 0
126
-
127
- # Over cap (count >= cap).
128
- if args.allow_over_cap:
129
- if not args.quiet:
130
- # Stderr so it surfaces alongside other warnings in CI logs;
131
- # banner is informational, not a failure.
132
- print(
133
- (
134
- f"\u26a0 verify:wip-cap: {count}/{cap} in pending/+active/ "
135
- "is OVER cap, but --allow-over-cap was passed (framework "
136
- "landing-day grace; consumers MUST NOT use this flag).\n"
137
- " Drain via task scope:demote / task scope:demote --batch "
138
- "--older-than-days 30 (#1119 umbrella v3)."
139
- ),
140
- file=sys.stderr,
141
- )
142
- return 0
143
-
144
- print(_format_refusal(count, cap, project_root), file=sys.stderr)
145
- return 1
146
-
147
-
148
- if __name__ == "__main__":
149
- raise SystemExit(main())
@@ -1,545 +0,0 @@
1
- #!/usr/bin/env python3
2
- """probe_session.py -- mechanical guard for probe artifact handoff (#1518c).
3
-
4
- Records whether a probe session is still interrogating or ready for artifact
5
- registration. Callers MUST invoke the guard helpers before writing probe output
6
- or updating ``completedStrategies`` for ``"probe"`` in ``plan.vbrief.json``.
7
-
8
- Session state lives at ``.deft/probe-session.json`` (gitignored, per-clone).
9
-
10
- Exit codes (CLI):
11
- 0 -- success
12
- 1 -- handoff blocked (interrogation still active)
13
- 2 -- usage / config error
14
-
15
- Usage:
16
- uv run python scripts/probe_session.py start --target <scope> [--branch <decision-branch>]
17
- uv run python scripts/probe_session.py record --question ... --answer ... --status locked
18
- uv run python scripts/probe_session.py set-branch --branch <decision-branch>
19
- uv run python scripts/probe_session.py complete
20
- uv run python scripts/probe_session.py status [--json]
21
- uv run python scripts/probe_session.py guard-artifact --path <artifact-path>
22
- uv run python scripts/probe_session.py guard-plan-registration
23
-
24
- Refs #1518 recommendation C.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import argparse
30
- import contextlib
31
- import json
32
- import os
33
- import subprocess
34
- import sys
35
- import tempfile
36
- from dataclasses import dataclass
37
- from datetime import UTC, datetime
38
- from pathlib import Path
39
- from typing import Any, Literal
40
-
41
- SCHEMA_VERSION: int = 1
42
- SESSION_RELPATH: tuple[str, str] = (".deft", "probe-session.json")
43
-
44
- STATE_INTERROGATE: Literal["interrogate"] = "interrogate"
45
- STATE_COMPLETE: Literal["complete"] = "complete"
46
- ProbeState = Literal["interrogate", "complete"]
47
-
48
- VALID_DECISION_STATUSES = frozenset({"locked", "deferred", "risk-accepted"})
49
-
50
-
51
- class ProbeHandoffBlockedError(Exception):
52
- """Raised when probe artifacts or plan registration are attempted too early."""
53
-
54
- def __init__(self, message: str, *, session: ProbeSession | None = None) -> None:
55
- super().__init__(message)
56
- self.session = session
57
-
58
-
59
- @dataclass(frozen=True)
60
- class ResolvedDecision:
61
- question: str
62
- answer: str
63
- status: str
64
-
65
- def to_dict(self) -> dict[str, str]:
66
- return {
67
- "question": self.question,
68
- "answer": self.answer,
69
- "status": self.status,
70
- }
71
-
72
- @classmethod
73
- def from_dict(cls, raw: object) -> ResolvedDecision | None:
74
- if not isinstance(raw, dict):
75
- return None
76
- question = raw.get("question")
77
- answer = raw.get("answer")
78
- status = raw.get("status")
79
- if not all(isinstance(v, str) and v.strip() for v in (question, answer, status)):
80
- return None
81
- if status not in VALID_DECISION_STATUSES:
82
- return None
83
- return cls(question=question.strip(), answer=answer.strip(), status=status.strip())
84
-
85
-
86
- @dataclass(frozen=True)
87
- class ProbeSession:
88
- schema_version: int
89
- state: ProbeState
90
- target: str
91
- current_branch: str
92
- resolved_decisions: tuple[ResolvedDecision, ...]
93
- started_at: datetime
94
- completed_at: datetime | None
95
-
96
- def to_dict(self) -> dict[str, Any]:
97
- payload: dict[str, Any] = {
98
- "schemaVersion": self.schema_version,
99
- "state": self.state,
100
- "target": self.target,
101
- "currentBranch": self.current_branch,
102
- "resolvedDecisions": [d.to_dict() for d in self.resolved_decisions],
103
- "startedAt": _format_timestamp(self.started_at),
104
- }
105
- if self.completed_at is not None:
106
- payload["completedAt"] = _format_timestamp(self.completed_at)
107
- return payload
108
-
109
-
110
- def _session_path(project_root: Path) -> Path:
111
- return project_root.joinpath(*SESSION_RELPATH)
112
-
113
-
114
- def _format_timestamp(value: datetime) -> str:
115
- instant = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
116
- return instant.strftime("%Y-%m-%dT%H:%M:%SZ")
117
-
118
-
119
- def _parse_timestamp(raw: object) -> datetime | None:
120
- if not isinstance(raw, str) or not raw:
121
- return None
122
- normalised = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
123
- try:
124
- parsed = datetime.fromisoformat(normalised)
125
- except ValueError:
126
- return None
127
- if parsed.tzinfo is None:
128
- parsed = parsed.replace(tzinfo=UTC)
129
- return parsed
130
-
131
-
132
- def _detect_git_branch(project_root: Path) -> str:
133
- try:
134
- result = subprocess.run(
135
- ["git", "symbolic-ref", "--short", "HEAD"],
136
- cwd=str(project_root),
137
- capture_output=True,
138
- text=True,
139
- encoding="utf-8",
140
- errors="replace",
141
- timeout=10,
142
- check=False,
143
- )
144
- except (FileNotFoundError, subprocess.TimeoutExpired):
145
- return ""
146
- if result.returncode == 0:
147
- branch = (result.stdout or "").strip()
148
- if branch:
149
- return branch
150
- try:
151
- rev_result = subprocess.run(
152
- ["git", "rev-parse", "--short", "HEAD"],
153
- cwd=str(project_root),
154
- capture_output=True,
155
- text=True,
156
- encoding="utf-8",
157
- errors="replace",
158
- timeout=10,
159
- check=False,
160
- )
161
- except (FileNotFoundError, subprocess.TimeoutExpired):
162
- return ""
163
- if rev_result.returncode == 0:
164
- sha = (rev_result.stdout or "").strip()
165
- if sha:
166
- return f"detached:{sha}"
167
- return ""
168
-
169
-
170
- def read(project_root: Path) -> ProbeSession | None:
171
- """Read ``.deft/probe-session.json`` from ``project_root``."""
172
- session_file = _session_path(project_root)
173
- if not session_file.is_file():
174
- return None
175
- try:
176
- payload = json.loads(session_file.read_text(encoding="utf-8"))
177
- except (OSError, json.JSONDecodeError, ValueError):
178
- return None
179
- if not isinstance(payload, dict):
180
- return None
181
- if payload.get("schemaVersion") != SCHEMA_VERSION:
182
- return None
183
- state = payload.get("state")
184
- if state not in (STATE_INTERROGATE, STATE_COMPLETE):
185
- return None
186
- target = payload.get("target")
187
- current_branch = payload.get("currentBranch")
188
- if not isinstance(target, str) or not target.strip():
189
- return None
190
- if not isinstance(current_branch, str):
191
- return None
192
- started_at = _parse_timestamp(payload.get("startedAt"))
193
- if started_at is None:
194
- return None
195
- completed_at = _parse_timestamp(payload.get("completedAt"))
196
- if state == STATE_COMPLETE and completed_at is None:
197
- return None
198
- if state == STATE_INTERROGATE and completed_at is not None:
199
- return None
200
- raw_decisions = payload.get("resolvedDecisions")
201
- if not isinstance(raw_decisions, list):
202
- return None
203
- decisions: list[ResolvedDecision] = []
204
- for item in raw_decisions:
205
- parsed = ResolvedDecision.from_dict(item)
206
- if parsed is None:
207
- return None
208
- decisions.append(parsed)
209
- return ProbeSession(
210
- schema_version=SCHEMA_VERSION,
211
- state=state,
212
- target=target.strip(),
213
- current_branch=current_branch.strip(),
214
- resolved_decisions=tuple(decisions),
215
- started_at=started_at,
216
- completed_at=completed_at,
217
- )
218
-
219
-
220
- def write(project_root: Path, session: ProbeSession) -> Path:
221
- """Atomically persist ``session`` to ``.deft/probe-session.json``."""
222
- session_file = _session_path(project_root)
223
- session_file.parent.mkdir(parents=True, exist_ok=True)
224
- tmp_fd, tmp_name = tempfile.mkstemp(
225
- prefix=".probe-session.",
226
- suffix=".json.tmp",
227
- dir=str(session_file.parent),
228
- )
229
- fdopen_succeeded = False
230
- try:
231
- fh = os.fdopen(tmp_fd, "w", encoding="utf-8", newline="\n")
232
- fdopen_succeeded = True
233
- try:
234
- json.dump(session.to_dict(), fh, indent=2, sort_keys=True)
235
- fh.write("\n")
236
- fh.flush()
237
- with contextlib.suppress(OSError):
238
- os.fsync(fh.fileno())
239
- finally:
240
- fh.close()
241
- os.replace(tmp_name, session_file)
242
- except Exception:
243
- if not fdopen_succeeded:
244
- with contextlib.suppress(OSError):
245
- os.close(tmp_fd)
246
- with contextlib.suppress(OSError):
247
- os.unlink(tmp_name)
248
- raise
249
- return session_file
250
-
251
-
252
- def start_session(
253
- project_root: Path,
254
- *,
255
- target: str,
256
- current_branch: str = "",
257
- now: datetime | None = None,
258
- ) -> ProbeSession:
259
- """Start (or replace) an interrogating probe session for ``target``."""
260
- scope = target.strip()
261
- if not scope:
262
- raise ValueError("target must be a non-empty scope name")
263
- branch = current_branch.strip() or _detect_git_branch(project_root)
264
- instant = now if now is not None else datetime.now(UTC)
265
- session = ProbeSession(
266
- schema_version=SCHEMA_VERSION,
267
- state=STATE_INTERROGATE,
268
- target=scope,
269
- current_branch=branch,
270
- resolved_decisions=(),
271
- started_at=instant,
272
- completed_at=None,
273
- )
274
- write(project_root, session)
275
- return session
276
-
277
-
278
- def record_decision(
279
- project_root: Path,
280
- *,
281
- question: str,
282
- answer: str,
283
- status: str,
284
- ) -> ProbeSession:
285
- """Append a resolved decision while the session is interrogating."""
286
- session = read(project_root)
287
- if session is None:
288
- raise ProbeHandoffBlockedError(
289
- "No active probe session. Start one with "
290
- "`uv run python scripts/probe_session.py start --target <scope>`."
291
- )
292
- if session.state != STATE_INTERROGATE:
293
- raise ProbeHandoffBlockedError(
294
- "Probe session is already complete; decisions cannot be appended.",
295
- session=session,
296
- )
297
- if status not in VALID_DECISION_STATUSES:
298
- raise ValueError(
299
- f"status must be one of {sorted(VALID_DECISION_STATUSES)}, got {status!r}"
300
- )
301
- q = question.strip()
302
- a = answer.strip()
303
- if not q or not a:
304
- raise ValueError("question and answer must be non-empty strings")
305
- updated = ProbeSession(
306
- schema_version=session.schema_version,
307
- state=session.state,
308
- target=session.target,
309
- current_branch=session.current_branch,
310
- resolved_decisions=session.resolved_decisions + (
311
- ResolvedDecision(question=q, answer=a, status=status),
312
- ),
313
- started_at=session.started_at,
314
- completed_at=session.completed_at,
315
- )
316
- write(project_root, updated)
317
- return updated
318
-
319
-
320
- def set_current_branch(project_root: Path, branch: str) -> ProbeSession:
321
- """Update the decision branch currently under interrogation."""
322
- session = read(project_root)
323
- if session is None:
324
- raise ProbeHandoffBlockedError(
325
- "No active probe session. Start one with "
326
- "`uv run python scripts/probe_session.py start --target <scope>`."
327
- )
328
- if session.state != STATE_INTERROGATE:
329
- raise ProbeHandoffBlockedError(
330
- "Probe session is already complete; current branch cannot change.",
331
- session=session,
332
- )
333
- updated = ProbeSession(
334
- schema_version=session.schema_version,
335
- state=session.state,
336
- target=session.target,
337
- current_branch=branch.strip(),
338
- resolved_decisions=session.resolved_decisions,
339
- started_at=session.started_at,
340
- completed_at=session.completed_at,
341
- )
342
- write(project_root, updated)
343
- return updated
344
-
345
-
346
- def mark_complete(project_root: Path, *, now: datetime | None = None) -> ProbeSession:
347
- """Mark the active probe session complete so artifact handoff is allowed."""
348
- session = read(project_root)
349
- if session is None:
350
- raise ProbeHandoffBlockedError(
351
- "No active probe session. Start one with "
352
- "`uv run python scripts/probe_session.py start --target <scope>`."
353
- )
354
- if session.state == STATE_COMPLETE:
355
- return session
356
- instant = now if now is not None else datetime.now(UTC)
357
- updated = ProbeSession(
358
- schema_version=session.schema_version,
359
- state=STATE_COMPLETE,
360
- target=session.target,
361
- current_branch=session.current_branch,
362
- resolved_decisions=session.resolved_decisions,
363
- started_at=session.started_at,
364
- completed_at=instant,
365
- )
366
- write(project_root, updated)
367
- return updated
368
-
369
-
370
- def _blocked_message(session: ProbeSession | None, action: str) -> str:
371
- if session is None:
372
- return (
373
- f"Probe handoff blocked for {action}: no active probe session. "
374
- "Start interrogation with "
375
- "`uv run python scripts/probe_session.py start --target <scope>` "
376
- "and finish with `... complete` only after transition criteria are met."
377
- )
378
- return (
379
- f"Probe handoff blocked for {action}: session state is "
380
- f"'{session.state}' (target={session.target!r}, "
381
- f"currentBranch={session.current_branch!r}, "
382
- f"resolvedDecisions={len(session.resolved_decisions)}). "
383
- "Continue interrogation until transition criteria are met, record decisions "
384
- "with `uv run python scripts/probe_session.py record ...`, then run "
385
- "`uv run python scripts/probe_session.py complete` before writing artifacts "
386
- "or updating completedStrategies.probe in plan.vbrief.json."
387
- )
388
-
389
-
390
- def require_handoff_allowed(project_root: Path, *, action: str) -> ProbeSession:
391
- """Return the session when handoff is allowed; raise otherwise."""
392
- session = read(project_root)
393
- if session is None or session.state != STATE_COMPLETE:
394
- raise ProbeHandoffBlockedError(_blocked_message(session, action), session=session)
395
- return session
396
-
397
-
398
- def guard_probe_artifact(project_root: Path, artifact_path: str) -> ProbeSession:
399
- """Guard writing a probe scope vBRIEF artifact."""
400
- action = f"probe artifact write ({artifact_path})"
401
- return require_handoff_allowed(project_root, action=action)
402
-
403
-
404
- def guard_plan_probe_registration(project_root: Path) -> ProbeSession:
405
- """Guard updating completedStrategies.probe in plan.vbrief.json."""
406
- return require_handoff_allowed(
407
- project_root,
408
- action="completedStrategies.probe registration in plan.vbrief.json",
409
- )
410
-
411
-
412
- def session_summary(session: ProbeSession) -> dict[str, Any]:
413
- """JSON-serialisable summary for CLI status output."""
414
- return {
415
- "state": session.state,
416
- "target": session.target,
417
- "currentBranch": session.current_branch,
418
- "resolvedDecisions": [d.to_dict() for d in session.resolved_decisions],
419
- "startedAt": _format_timestamp(session.started_at),
420
- "completedAt": (
421
- _format_timestamp(session.completed_at) if session.completed_at is not None else None
422
- ),
423
- }
424
-
425
-
426
- def main(argv: list[str] | None = None) -> int:
427
- parser = argparse.ArgumentParser(
428
- description="Mechanical guard for probe session state and artifact handoff."
429
- )
430
- parser.add_argument(
431
- "--project-root",
432
- type=Path,
433
- default=Path("."),
434
- help="Project root containing .deft/ (default: cwd)",
435
- )
436
- subparsers = parser.add_subparsers(dest="command", required=True)
437
-
438
- start_parser = subparsers.add_parser("start", help="Start an interrogating probe session")
439
- start_parser.add_argument("--target", required=True, help="Probe scope / feature slug")
440
- start_parser.add_argument(
441
- "--branch",
442
- default="",
443
- help="Decision branch under interrogation (defaults to git branch)",
444
- )
445
-
446
- record_parser = subparsers.add_parser("record", help="Record a resolved decision")
447
- record_parser.add_argument("--question", required=True)
448
- record_parser.add_argument("--answer", required=True)
449
- record_parser.add_argument(
450
- "--status",
451
- required=True,
452
- choices=sorted(VALID_DECISION_STATUSES),
453
- )
454
-
455
- branch_parser = subparsers.add_parser(
456
- "set-branch",
457
- help="Update the decision branch under interrogation",
458
- )
459
- branch_parser.add_argument("--branch", required=True)
460
-
461
- subparsers.add_parser("complete", help="Mark the probe session complete")
462
- status_parser = subparsers.add_parser("status", help="Show current probe session state")
463
- status_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
464
-
465
- guard_artifact_parser = subparsers.add_parser(
466
- "guard-artifact",
467
- help="Fail unless probe artifact handoff is allowed",
468
- )
469
- guard_artifact_parser.add_argument(
470
- "--path",
471
- required=True,
472
- help="Probe artifact path (e.g. vbrief/proposed/my-app-probe.vbrief.json)",
473
- )
474
-
475
- subparsers.add_parser(
476
- "guard-plan-registration",
477
- help="Fail unless completedStrategies.probe registration is allowed",
478
- )
479
-
480
- args = parser.parse_args(argv)
481
- project_root = args.project_root.resolve()
482
-
483
- try:
484
- if args.command == "start":
485
- session = start_session(project_root, target=args.target, current_branch=args.branch)
486
- print(
487
- f"Probe session started: state={session.state}, target={session.target!r}, "
488
- f"currentBranch={session.current_branch!r}"
489
- )
490
- return 0
491
- if args.command == "record":
492
- session = record_decision(
493
- project_root,
494
- question=args.question,
495
- answer=args.answer,
496
- status=args.status,
497
- )
498
- print(
499
- f"Recorded decision ({len(session.resolved_decisions)} total); "
500
- f"state={session.state}"
501
- )
502
- return 0
503
- if args.command == "set-branch":
504
- session = set_current_branch(project_root, args.branch)
505
- print(f"Current branch set to {session.current_branch!r}; state={session.state}")
506
- return 0
507
- if args.command == "complete":
508
- session = mark_complete(project_root)
509
- print(f"Probe session marked complete for target={session.target!r}")
510
- return 0
511
- if args.command == "status":
512
- session = read(project_root)
513
- if session is None:
514
- print("No active probe session.")
515
- return 0
516
- if args.json:
517
- print(json.dumps(session_summary(session), indent=2, sort_keys=True))
518
- else:
519
- summary = session_summary(session)
520
- print(f"state: {summary['state']}")
521
- print(f"target: {summary['target']}")
522
- print(f"currentBranch: {summary['currentBranch']}")
523
- print(f"resolvedDecisions: {len(summary['resolvedDecisions'])}")
524
- return 0
525
- if args.command == "guard-artifact":
526
- guard_probe_artifact(project_root, args.path)
527
- print(f"Probe artifact handoff allowed: {args.path}")
528
- return 0
529
- if args.command == "guard-plan-registration":
530
- guard_plan_probe_registration(project_root)
531
- print("completedStrategies.probe registration allowed")
532
- return 0
533
- except ProbeHandoffBlockedError as exc:
534
- print(str(exc), file=sys.stderr)
535
- return 1
536
- except ValueError as exc:
537
- print(str(exc), file=sys.stderr)
538
- return 2
539
-
540
- print(f"Unknown command: {args.command}", file=sys.stderr)
541
- return 2
542
-
543
-
544
- if __name__ == "__main__":
545
- sys.exit(main())