@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,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())