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