@deftai/directive-content 0.55.2 → 0.56.0

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