@deftai/directive-content 0.55.2 → 0.56.1
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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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())
|