@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,609 @@
|
|
|
1
|
+
"""ritual_sentinel.py -- session-start ritual sentinel + resume nudge (#1269).
|
|
2
|
+
|
|
3
|
+
Public surface
|
|
4
|
+
--------------
|
|
5
|
+
|
|
6
|
+
* :func:`read` -- read ``.deft/last-session.json`` from a project root and
|
|
7
|
+
return a :class:`Sentinel` dataclass. Fails open: missing, corrupt,
|
|
8
|
+
or schema-mismatched sentinels return ``None`` without raising. Caller
|
|
9
|
+
treats ``None`` as "fresh session, no resume context".
|
|
10
|
+
* :func:`write` -- atomically write a sentinel snapshot at the end of the
|
|
11
|
+
session-start ritual. Uses ``os.replace`` so a crashed writer never
|
|
12
|
+
leaves a partial file on disk; the previous sentinel is preserved
|
|
13
|
+
intact until the new one is fully durable.
|
|
14
|
+
* :func:`compute_resume_signal` -- evaluate gating predicates against a
|
|
15
|
+
sentinel snapshot + current time and return the formatted resume-nudge
|
|
16
|
+
line, OR ``None`` when the ritual MUST stay silent. The gating
|
|
17
|
+
predicate is conjunctive: ALL of {sentinel parses, ``lastActiveVbrief``
|
|
18
|
+
is still under ``vbrief/active/``, >= 2h since the recorded timestamp,
|
|
19
|
+
``lastActiveVbrief`` references a file that exists on disk} must hold.
|
|
20
|
+
|
|
21
|
+
Sentinel schema (v1)
|
|
22
|
+
--------------------
|
|
23
|
+
|
|
24
|
+
::
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"schemaVersion": 1,
|
|
28
|
+
"deftVersion": "0.32.1",
|
|
29
|
+
"timestamp": "2026-05-22T16:48:35Z",
|
|
30
|
+
"lastActiveVbrief": "vbrief/active/2026-05-13-foo.vbrief.json",
|
|
31
|
+
"lastBranch": "feat/foo-bar"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
``deftVersion`` is recorded for forward compatibility with the deferred
|
|
35
|
+
``task whats-new --since=<version>`` digest verb (see #1269 non-goals);
|
|
36
|
+
the v1 emission logic does NOT consume it, so a sentinel that omits
|
|
37
|
+
``deftVersion`` still fires the resume nudge when the remaining gating
|
|
38
|
+
predicates hold.
|
|
39
|
+
|
|
40
|
+
Failure-mode discipline (fail-open, #1269 AC)
|
|
41
|
+
---------------------------------------------
|
|
42
|
+
|
|
43
|
+
* Missing sentinel file -> :func:`read` returns ``None``;
|
|
44
|
+
:func:`compute_resume_signal` returns ``None``.
|
|
45
|
+
* Corrupt JSON (decode error) -> ``read`` returns ``None``.
|
|
46
|
+
* Schema version mismatch (``schemaVersion != 1``) -> ``read`` returns
|
|
47
|
+
``None``.
|
|
48
|
+
* Missing required fields (``timestamp`` / ``lastActiveVbrief`` /
|
|
49
|
+
``lastBranch``) -> ``read`` returns ``None``. ``deftVersion`` is
|
|
50
|
+
optional.
|
|
51
|
+
* Unparseable timestamp -> ``read`` returns ``None``.
|
|
52
|
+
* ``lastActiveVbrief`` no longer under ``vbrief/active/`` (promoted to
|
|
53
|
+
``completed/`` or ``cancelled/``) -> :func:`compute_resume_signal`
|
|
54
|
+
returns ``None`` even when the sentinel parses.
|
|
55
|
+
* ``lastActiveVbrief`` path missing on disk (branch-switched-away or
|
|
56
|
+
filesystem-deleted) -> :func:`compute_resume_signal` returns ``None``.
|
|
57
|
+
* < 2h since recorded timestamp -> :func:`compute_resume_signal` returns
|
|
58
|
+
``None`` (avoid nagging on terminal-restart within an active session).
|
|
59
|
+
|
|
60
|
+
The module never raises out of :func:`read` or
|
|
61
|
+
:func:`compute_resume_signal`; the ritual continues silently in every
|
|
62
|
+
adverse case.
|
|
63
|
+
|
|
64
|
+
Refs #1269.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import contextlib
|
|
70
|
+
import json
|
|
71
|
+
import logging
|
|
72
|
+
import os
|
|
73
|
+
import tempfile
|
|
74
|
+
from dataclasses import dataclass
|
|
75
|
+
from datetime import UTC, datetime, timedelta
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
LOG = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
#: Schema version emitted by :func:`write` and required by :func:`read`.
|
|
82
|
+
SCHEMA_VERSION: int = 1
|
|
83
|
+
|
|
84
|
+
#: Filesystem-relative location of the per-clone sentinel. Never
|
|
85
|
+
#: committed -- consumer projections selectively gitignore this file
|
|
86
|
+
#: while preserving the trackable ``.deft/core/`` framework payload.
|
|
87
|
+
SENTINEL_RELPATH: tuple[str, str] = (".deft", "last-session.json")
|
|
88
|
+
|
|
89
|
+
#: Filesystem-relative location of the fail-closed session ritual state
|
|
90
|
+
#: (#1348). Separate from :data:`SENTINEL_RELPATH` because the existing
|
|
91
|
+
#: last-session sentinel is intentionally fail-open while this verifier
|
|
92
|
+
#: must fail closed. Also selectively gitignored in consumer projections.
|
|
93
|
+
RITUAL_STATE_RELPATH: tuple[str, str] = (".deft", "ritual-state.json")
|
|
94
|
+
|
|
95
|
+
#: Schema version emitted by the ritual-state writer and required by the
|
|
96
|
+
#: strict reader.
|
|
97
|
+
RITUAL_STATE_SCHEMA_VERSION: int = 1
|
|
98
|
+
|
|
99
|
+
#: Minimum time delta since the recorded ``timestamp`` before the resume
|
|
100
|
+
#: nudge fires. Guards against nagging on terminal-restart within an
|
|
101
|
+
#: active session. Matches the threshold documented in #1269 AC.
|
|
102
|
+
MIN_RESUME_AGE: timedelta = timedelta(hours=2)
|
|
103
|
+
|
|
104
|
+
#: Path prefix the recorded ``lastActiveVbrief`` MUST still live under
|
|
105
|
+
#: for the resume nudge to fire. Promotion to ``vbrief/completed/`` or
|
|
106
|
+
#: ``vbrief/cancelled/`` silences the nudge because the work is done.
|
|
107
|
+
ACTIVE_VBRIEF_PREFIX: str = "vbrief/active/"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Sentinel:
|
|
112
|
+
"""Parsed sentinel snapshot.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
schema_version: Always ``1`` for v1; future writers may bump and
|
|
116
|
+
the reader rejects unknown versions (fail-open -> ``None``).
|
|
117
|
+
deft_version: Framework version captured at write time (e.g.
|
|
118
|
+
``"0.32.1"``). Optional -- the field is reserved for the
|
|
119
|
+
deferred ``task whats-new`` digest verb and is not consumed
|
|
120
|
+
by the v1 resume-nudge emission logic. Empty string when
|
|
121
|
+
absent from the sentinel.
|
|
122
|
+
timestamp: UTC instant the session-start ritual concluded.
|
|
123
|
+
Carried as a :class:`datetime` (timezone-aware) so callers
|
|
124
|
+
can compute the elapsed delta directly.
|
|
125
|
+
last_active_vbrief: Relative path to the in-flight scope vBRIEF
|
|
126
|
+
the operator was last working on, as recorded by the
|
|
127
|
+
session-start ritual writer. POSIX-style separators.
|
|
128
|
+
last_branch: Git branch the operator was on when the ritual ran.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
schema_version: int
|
|
132
|
+
deft_version: str
|
|
133
|
+
timestamp: datetime
|
|
134
|
+
last_active_vbrief: str
|
|
135
|
+
last_branch: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class RitualState:
|
|
140
|
+
"""Strictly parsed ``.deft/ritual-state.json`` snapshot (#1348)."""
|
|
141
|
+
|
|
142
|
+
schema_version: int
|
|
143
|
+
session_id: str
|
|
144
|
+
git_head: str
|
|
145
|
+
worktree_path: str
|
|
146
|
+
started_at: datetime
|
|
147
|
+
quick_steps: dict[str, dict[str, Any]]
|
|
148
|
+
gated_steps: dict[str, dict[str, Any]]
|
|
149
|
+
raw: dict[str, Any]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sentinel_path(project_root: Path) -> Path:
|
|
153
|
+
return project_root.joinpath(*SENTINEL_RELPATH)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ritual_state_path(project_root: Path) -> Path:
|
|
157
|
+
"""Return the absolute ``.deft/ritual-state.json`` path."""
|
|
158
|
+
return project_root.joinpath(*RITUAL_STATE_RELPATH)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_timestamp(raw: object) -> datetime | None:
|
|
162
|
+
"""Parse an ISO-8601 timestamp string into a tz-aware datetime.
|
|
163
|
+
|
|
164
|
+
Accepts both ``"...Z"`` (canonical writer output) and
|
|
165
|
+
``"...+00:00"`` (output of :meth:`datetime.isoformat`). Returns
|
|
166
|
+
``None`` on any parse failure so the caller can fail open.
|
|
167
|
+
"""
|
|
168
|
+
if not isinstance(raw, str) or not raw:
|
|
169
|
+
return None
|
|
170
|
+
# Python <3.11 ``fromisoformat`` does not accept the trailing ``Z``;
|
|
171
|
+
# 3.11+ does, but normalising avoids surprises if a future writer
|
|
172
|
+
# emits a different shape.
|
|
173
|
+
normalised = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
174
|
+
try:
|
|
175
|
+
parsed = datetime.fromisoformat(normalised)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return None
|
|
178
|
+
if parsed.tzinfo is None:
|
|
179
|
+
# Treat naive timestamps as UTC (the writer always emits UTC);
|
|
180
|
+
# be permissive on read to remain fail-open.
|
|
181
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
182
|
+
return parsed
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _timestamp_iso(now: datetime | None = None) -> str:
|
|
186
|
+
instant = now if now is not None else datetime.now(UTC)
|
|
187
|
+
instant = instant.replace(tzinfo=UTC) if instant.tzinfo is None else instant.astimezone(UTC)
|
|
188
|
+
return instant.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ritual_step(
|
|
192
|
+
*,
|
|
193
|
+
ok: bool,
|
|
194
|
+
ts: datetime | None = None,
|
|
195
|
+
deferred_reason: str | None = None,
|
|
196
|
+
exit_code: int | None = None,
|
|
197
|
+
message: str | None = None,
|
|
198
|
+
command: list[str] | tuple[str, ...] | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Return a canonical ritual step payload for ``ritual-state.json``."""
|
|
201
|
+
payload: dict[str, Any] = {
|
|
202
|
+
"ok": ok,
|
|
203
|
+
"ts": _timestamp_iso(ts),
|
|
204
|
+
}
|
|
205
|
+
if deferred_reason:
|
|
206
|
+
payload["deferred_reason"] = deferred_reason
|
|
207
|
+
if exit_code is not None:
|
|
208
|
+
payload["exit_code"] = exit_code
|
|
209
|
+
if message:
|
|
210
|
+
payload["message"] = message
|
|
211
|
+
if command:
|
|
212
|
+
payload["command"] = [str(part) for part in command]
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def new_ritual_state_payload(
|
|
217
|
+
*,
|
|
218
|
+
session_id: str,
|
|
219
|
+
git_head: str,
|
|
220
|
+
worktree_path: str,
|
|
221
|
+
started_at: datetime | None = None,
|
|
222
|
+
quick_steps: dict[str, dict[str, Any]] | None = None,
|
|
223
|
+
gated_steps: dict[str, dict[str, Any]] | None = None,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Build the canonical top-level ritual-state JSON payload."""
|
|
226
|
+
return {
|
|
227
|
+
"schemaVersion": RITUAL_STATE_SCHEMA_VERSION,
|
|
228
|
+
"session_id": session_id,
|
|
229
|
+
"git_head": git_head,
|
|
230
|
+
"worktree_path": worktree_path,
|
|
231
|
+
"started_at": _timestamp_iso(started_at),
|
|
232
|
+
"quick_steps": quick_steps or {},
|
|
233
|
+
"gated_steps": gated_steps or {},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validate_steps(raw: object, key: str) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
|
|
238
|
+
if not isinstance(raw, dict):
|
|
239
|
+
return None, f"{key} must be an object"
|
|
240
|
+
steps: dict[str, dict[str, Any]] = {}
|
|
241
|
+
for name, value in raw.items():
|
|
242
|
+
if not isinstance(name, str) or not name:
|
|
243
|
+
return None, f"{key} contains a non-string step name"
|
|
244
|
+
if not isinstance(value, dict):
|
|
245
|
+
return None, f"{key}.{name} must be an object"
|
|
246
|
+
ok = value.get("ok")
|
|
247
|
+
if not isinstance(ok, bool):
|
|
248
|
+
return None, f"{key}.{name}.ok must be a boolean"
|
|
249
|
+
if _parse_timestamp(value.get("ts")) is None:
|
|
250
|
+
return None, f"{key}.{name}.ts must be an ISO-8601 timestamp"
|
|
251
|
+
deferred = value.get("deferred_reason")
|
|
252
|
+
if deferred is not None and not isinstance(deferred, str):
|
|
253
|
+
return None, f"{key}.{name}.deferred_reason must be a string"
|
|
254
|
+
exit_code = value.get("exit_code")
|
|
255
|
+
if exit_code is not None and (
|
|
256
|
+
not isinstance(exit_code, int) or isinstance(exit_code, bool)
|
|
257
|
+
):
|
|
258
|
+
return None, f"{key}.{name}.exit_code must be an integer"
|
|
259
|
+
message = value.get("message")
|
|
260
|
+
if message is not None and not isinstance(message, str):
|
|
261
|
+
return None, f"{key}.{name}.message must be a string"
|
|
262
|
+
command = value.get("command")
|
|
263
|
+
if command is not None and (
|
|
264
|
+
not isinstance(command, list) or not all(isinstance(part, str) for part in command)
|
|
265
|
+
):
|
|
266
|
+
return None, f"{key}.{name}.command must be an array of strings"
|
|
267
|
+
steps[name] = dict(value)
|
|
268
|
+
return steps, None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def read_ritual_state(project_root: Path) -> tuple[RitualState | None, str | None]:
|
|
272
|
+
"""Strictly read ``.deft/ritual-state.json``.
|
|
273
|
+
|
|
274
|
+
Unlike :func:`read`, this is the fail-closed #1348 surface. Missing
|
|
275
|
+
state, corrupt JSON, schema mismatch, and malformed fields return a
|
|
276
|
+
diagnostic error string for callers to turn into gate failures.
|
|
277
|
+
"""
|
|
278
|
+
state_file = ritual_state_path(project_root)
|
|
279
|
+
try:
|
|
280
|
+
if not state_file.is_file():
|
|
281
|
+
return None, f"ritual state missing at {state_file}"
|
|
282
|
+
except OSError as exc:
|
|
283
|
+
return None, f"ritual state unreadable at {state_file}: {exc}"
|
|
284
|
+
try:
|
|
285
|
+
payload = json.loads(state_file.read_text(encoding="utf-8"))
|
|
286
|
+
except json.JSONDecodeError as exc:
|
|
287
|
+
return None, f"ritual state is not valid JSON: {exc.msg} (line {exc.lineno})"
|
|
288
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
289
|
+
return None, f"ritual state cannot be read: {exc}"
|
|
290
|
+
if not isinstance(payload, dict):
|
|
291
|
+
return None, "ritual state top-level value must be an object"
|
|
292
|
+
if payload.get("schemaVersion") != RITUAL_STATE_SCHEMA_VERSION:
|
|
293
|
+
return None, (
|
|
294
|
+
"ritual state schemaVersion mismatch "
|
|
295
|
+
f"(got {payload.get('schemaVersion')!r}, want {RITUAL_STATE_SCHEMA_VERSION})"
|
|
296
|
+
)
|
|
297
|
+
session_id = payload.get("session_id")
|
|
298
|
+
git_head = payload.get("git_head")
|
|
299
|
+
worktree_path = payload.get("worktree_path")
|
|
300
|
+
started_at = _parse_timestamp(payload.get("started_at"))
|
|
301
|
+
for field_name, value in (
|
|
302
|
+
("session_id", session_id),
|
|
303
|
+
("git_head", git_head),
|
|
304
|
+
("worktree_path", worktree_path),
|
|
305
|
+
):
|
|
306
|
+
if not isinstance(value, str) or not value:
|
|
307
|
+
return None, f"ritual state {field_name} must be a non-empty string"
|
|
308
|
+
if started_at is None:
|
|
309
|
+
return None, "ritual state started_at must be an ISO-8601 timestamp"
|
|
310
|
+
quick_steps, quick_err = _validate_steps(payload.get("quick_steps"), "quick_steps")
|
|
311
|
+
if quick_err is not None or quick_steps is None:
|
|
312
|
+
return None, quick_err or "quick_steps invalid"
|
|
313
|
+
gated_steps, gated_err = _validate_steps(payload.get("gated_steps"), "gated_steps")
|
|
314
|
+
if gated_err is not None or gated_steps is None:
|
|
315
|
+
return None, gated_err or "gated_steps invalid"
|
|
316
|
+
return (
|
|
317
|
+
RitualState(
|
|
318
|
+
schema_version=RITUAL_STATE_SCHEMA_VERSION,
|
|
319
|
+
session_id=session_id,
|
|
320
|
+
git_head=git_head,
|
|
321
|
+
worktree_path=worktree_path,
|
|
322
|
+
started_at=started_at,
|
|
323
|
+
quick_steps=quick_steps,
|
|
324
|
+
gated_steps=gated_steps,
|
|
325
|
+
raw=dict(payload),
|
|
326
|
+
),
|
|
327
|
+
None,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def write_ritual_state(project_root: Path, payload: dict[str, Any]) -> Path:
|
|
332
|
+
"""Atomically write the strict ``.deft/ritual-state.json`` payload."""
|
|
333
|
+
state_file = ritual_state_path(project_root)
|
|
334
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
336
|
+
prefix=".ritual-state.",
|
|
337
|
+
suffix=".json.tmp",
|
|
338
|
+
dir=str(state_file.parent),
|
|
339
|
+
)
|
|
340
|
+
fdopen_succeeded = False
|
|
341
|
+
try:
|
|
342
|
+
fh = os.fdopen(tmp_fd, "w", encoding="utf-8", newline="\n")
|
|
343
|
+
fdopen_succeeded = True
|
|
344
|
+
try:
|
|
345
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
346
|
+
fh.write("\n")
|
|
347
|
+
fh.flush()
|
|
348
|
+
with contextlib.suppress(OSError):
|
|
349
|
+
os.fsync(fh.fileno())
|
|
350
|
+
finally:
|
|
351
|
+
fh.close()
|
|
352
|
+
os.replace(tmp_name, state_file)
|
|
353
|
+
except Exception:
|
|
354
|
+
if not fdopen_succeeded:
|
|
355
|
+
with contextlib.suppress(OSError):
|
|
356
|
+
os.close(tmp_fd)
|
|
357
|
+
with contextlib.suppress(OSError):
|
|
358
|
+
os.unlink(tmp_name)
|
|
359
|
+
raise
|
|
360
|
+
return state_file
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def record_ritual_step(
|
|
364
|
+
project_root: Path,
|
|
365
|
+
*,
|
|
366
|
+
tier: str,
|
|
367
|
+
step_name: str,
|
|
368
|
+
step: dict[str, Any],
|
|
369
|
+
) -> Path:
|
|
370
|
+
"""Read-modify-write a single ritual step in the strict state file."""
|
|
371
|
+
state, err = read_ritual_state(project_root)
|
|
372
|
+
if state is None:
|
|
373
|
+
raise ValueError(err or "ritual state missing")
|
|
374
|
+
if tier not in {"quick", "gated"}:
|
|
375
|
+
raise ValueError(f"tier must be 'quick' or 'gated', got {tier!r}")
|
|
376
|
+
payload = dict(state.raw)
|
|
377
|
+
key = "quick_steps" if tier == "quick" else "gated_steps"
|
|
378
|
+
steps = dict(payload.get(key, {}))
|
|
379
|
+
steps[step_name] = step
|
|
380
|
+
payload[key] = steps
|
|
381
|
+
return write_ritual_state(project_root, payload)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read(project_root: Path) -> Sentinel | None:
|
|
385
|
+
"""Read ``.deft/last-session.json`` from ``project_root``.
|
|
386
|
+
|
|
387
|
+
Returns ``None`` on missing file, corrupt JSON, schema-version
|
|
388
|
+
mismatch, missing required field, or unparseable timestamp. Never
|
|
389
|
+
raises -- the ritual MUST continue silently on any adverse case.
|
|
390
|
+
"""
|
|
391
|
+
sentinel_file = _sentinel_path(project_root)
|
|
392
|
+
try:
|
|
393
|
+
if not sentinel_file.is_file():
|
|
394
|
+
return None
|
|
395
|
+
except OSError as exc:
|
|
396
|
+
# ``.deft/`` parent has restrictive permissions or is otherwise
|
|
397
|
+
# unreadable -- fail open so the documented never-raise contract
|
|
398
|
+
# holds even on a hostile filesystem.
|
|
399
|
+
LOG.debug("ritual_sentinel.read: is_file failed at %s: %s", sentinel_file, exc)
|
|
400
|
+
return None
|
|
401
|
+
try:
|
|
402
|
+
raw_text = sentinel_file.read_text(encoding="utf-8")
|
|
403
|
+
except (OSError, ValueError) as exc:
|
|
404
|
+
# ValueError (UnicodeDecodeError subclass) -- sentinel file
|
|
405
|
+
# contains non-UTF-8 bytes or truncated multi-byte sequence.
|
|
406
|
+
# OSError -- transient filesystem error. Fail open in both.
|
|
407
|
+
LOG.debug("ritual_sentinel.read: read failed at %s: %s", sentinel_file, exc)
|
|
408
|
+
return None
|
|
409
|
+
try:
|
|
410
|
+
payload = json.loads(raw_text)
|
|
411
|
+
except json.JSONDecodeError as exc:
|
|
412
|
+
LOG.debug("ritual_sentinel.read: JSON decode failed: %s", exc)
|
|
413
|
+
return None
|
|
414
|
+
if not isinstance(payload, dict):
|
|
415
|
+
return None
|
|
416
|
+
schema_version = payload.get("schemaVersion")
|
|
417
|
+
if schema_version != SCHEMA_VERSION:
|
|
418
|
+
LOG.debug(
|
|
419
|
+
"ritual_sentinel.read: schemaVersion mismatch (got %r, want %r)",
|
|
420
|
+
schema_version,
|
|
421
|
+
SCHEMA_VERSION,
|
|
422
|
+
)
|
|
423
|
+
return None
|
|
424
|
+
timestamp = _parse_timestamp(payload.get("timestamp"))
|
|
425
|
+
if timestamp is None:
|
|
426
|
+
return None
|
|
427
|
+
last_active_vbrief = payload.get("lastActiveVbrief")
|
|
428
|
+
if not isinstance(last_active_vbrief, str) or not last_active_vbrief:
|
|
429
|
+
return None
|
|
430
|
+
last_branch = payload.get("lastBranch")
|
|
431
|
+
if not isinstance(last_branch, str) or not last_branch:
|
|
432
|
+
return None
|
|
433
|
+
deft_version_raw = payload.get("deftVersion", "")
|
|
434
|
+
deft_version = deft_version_raw if isinstance(deft_version_raw, str) else ""
|
|
435
|
+
return Sentinel(
|
|
436
|
+
schema_version=schema_version,
|
|
437
|
+
deft_version=deft_version,
|
|
438
|
+
timestamp=timestamp,
|
|
439
|
+
last_active_vbrief=last_active_vbrief,
|
|
440
|
+
last_branch=last_branch,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def write(
|
|
445
|
+
project_root: Path,
|
|
446
|
+
*,
|
|
447
|
+
deft_version: str,
|
|
448
|
+
last_active_vbrief: str,
|
|
449
|
+
last_branch: str,
|
|
450
|
+
now: datetime | None = None,
|
|
451
|
+
) -> Path:
|
|
452
|
+
"""Atomically write the sentinel for ``project_root``.
|
|
453
|
+
|
|
454
|
+
Returns the path written. The write is atomic: a temp file is
|
|
455
|
+
created in the same directory and renamed via :func:`os.replace`,
|
|
456
|
+
so a crashed writer never leaves a partial file -- callers see the
|
|
457
|
+
previous sentinel (or no sentinel) until the rename completes.
|
|
458
|
+
|
|
459
|
+
The recorded timestamp is always UTC with a trailing ``Z`` so the
|
|
460
|
+
on-disk shape matches the issue body's example payload. ``now``
|
|
461
|
+
defaults to ``datetime.now(timezone.utc)`` and is exposed for tests
|
|
462
|
+
that want to pin a deterministic instant.
|
|
463
|
+
"""
|
|
464
|
+
sentinel_file = _sentinel_path(project_root)
|
|
465
|
+
sentinel_file.parent.mkdir(parents=True, exist_ok=True)
|
|
466
|
+
instant = now if now is not None else datetime.now(UTC)
|
|
467
|
+
instant = instant.replace(tzinfo=UTC) if instant.tzinfo is None else instant.astimezone(UTC)
|
|
468
|
+
# Canonical writer output: trailing ``Z`` (matches the issue body's
|
|
469
|
+
# example) instead of ``+00:00`` so the on-disk shape is stable
|
|
470
|
+
# across Python versions / platforms.
|
|
471
|
+
timestamp_iso = instant.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
472
|
+
payload = {
|
|
473
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
474
|
+
"deftVersion": deft_version,
|
|
475
|
+
"timestamp": timestamp_iso,
|
|
476
|
+
"lastActiveVbrief": last_active_vbrief.replace("\\", "/"),
|
|
477
|
+
"lastBranch": last_branch,
|
|
478
|
+
}
|
|
479
|
+
# ``delete=False`` so we can name the temp file and rename it; the
|
|
480
|
+
# caller is responsible for cleanup if the rename never happens
|
|
481
|
+
# (the ``except`` branch below removes the partial file).
|
|
482
|
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
483
|
+
prefix=".last-session.",
|
|
484
|
+
suffix=".json.tmp",
|
|
485
|
+
dir=str(sentinel_file.parent),
|
|
486
|
+
)
|
|
487
|
+
fdopen_succeeded = False
|
|
488
|
+
try:
|
|
489
|
+
fh = os.fdopen(tmp_fd, "w", encoding="utf-8", newline="\n")
|
|
490
|
+
fdopen_succeeded = True
|
|
491
|
+
try:
|
|
492
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
493
|
+
fh.write("\n")
|
|
494
|
+
fh.flush()
|
|
495
|
+
# fsync is best-effort; some filesystems (notably tmpfs on
|
|
496
|
+
# CI sandboxes) do not implement it. The atomic rename is
|
|
497
|
+
# the load-bearing durability guarantee.
|
|
498
|
+
with contextlib.suppress(OSError):
|
|
499
|
+
os.fsync(fh.fileno())
|
|
500
|
+
finally:
|
|
501
|
+
fh.close()
|
|
502
|
+
os.replace(tmp_name, sentinel_file)
|
|
503
|
+
except Exception:
|
|
504
|
+
# Roll back the partial temp file so it does not accumulate on
|
|
505
|
+
# repeated failure paths. Best-effort -- if the unlink itself
|
|
506
|
+
# fails we still want to surface the original exception. If
|
|
507
|
+
# os.fdopen never ran, ownership of the raw fd never moved off
|
|
508
|
+
# ``tmp_fd``, so we close it explicitly to avoid an fd leak.
|
|
509
|
+
if not fdopen_succeeded:
|
|
510
|
+
with contextlib.suppress(OSError):
|
|
511
|
+
os.close(tmp_fd)
|
|
512
|
+
with contextlib.suppress(OSError):
|
|
513
|
+
os.unlink(tmp_name)
|
|
514
|
+
raise
|
|
515
|
+
return sentinel_file
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def compute_resume_signal(
|
|
519
|
+
sentinel: Sentinel | None,
|
|
520
|
+
now: datetime,
|
|
521
|
+
project_root: Path,
|
|
522
|
+
) -> str | None:
|
|
523
|
+
"""Return the formatted resume-nudge line, or ``None`` when silent.
|
|
524
|
+
|
|
525
|
+
Emits the nudge ONLY when ALL of these conditions hold:
|
|
526
|
+
|
|
527
|
+
1. ``sentinel`` is not ``None`` (it parsed cleanly).
|
|
528
|
+
2. ``sentinel.last_active_vbrief`` is still under ``vbrief/active/``
|
|
529
|
+
(NOT promoted to ``completed/`` or ``cancelled/``).
|
|
530
|
+
3. ``now - sentinel.timestamp >= MIN_RESUME_AGE`` (>= 2h since the
|
|
531
|
+
last session ended; guards against nagging on terminal restart).
|
|
532
|
+
4. The referenced ``lastActiveVbrief`` file exists under
|
|
533
|
+
``project_root`` (defensive against branch-switched-away or
|
|
534
|
+
filesystem-deleted cases).
|
|
535
|
+
|
|
536
|
+
The format string mirrors the issue body example::
|
|
537
|
+
|
|
538
|
+
[deft] Last session: <path> (branch: <branch>), <Nh|Nm> ago.
|
|
539
|
+
Resume? Run `task vbrief:show <path>`.
|
|
540
|
+
|
|
541
|
+
For deltas >= 1h the elapsed time is rendered as ``<N>h``; for the
|
|
542
|
+
(rare) edge case of a sentinel that exists but is just under the
|
|
543
|
+
2h gate the function returns ``None`` rather than rendering a
|
|
544
|
+
minute-only line -- the minutes spelling is reserved for future
|
|
545
|
+
surfaces that may lower the gate.
|
|
546
|
+
"""
|
|
547
|
+
if sentinel is None:
|
|
548
|
+
return None
|
|
549
|
+
last_active = sentinel.last_active_vbrief.replace("\\", "/")
|
|
550
|
+
if not last_active.startswith(ACTIVE_VBRIEF_PREFIX):
|
|
551
|
+
return None
|
|
552
|
+
# Normalise ``now`` to UTC so the delta is comparable regardless of
|
|
553
|
+
# whether the caller passed a local or UTC instant.
|
|
554
|
+
now_utc = now.replace(tzinfo=UTC) if now.tzinfo is None else now.astimezone(UTC)
|
|
555
|
+
elapsed = now_utc - sentinel.timestamp
|
|
556
|
+
if elapsed < MIN_RESUME_AGE:
|
|
557
|
+
return None
|
|
558
|
+
vbrief_path = project_root / last_active
|
|
559
|
+
try:
|
|
560
|
+
exists_on_disk = vbrief_path.is_file()
|
|
561
|
+
except OSError:
|
|
562
|
+
# Permission denied or transient filesystem error -- fail open
|
|
563
|
+
# so the never-raise contract holds even on a hostile mount.
|
|
564
|
+
return None
|
|
565
|
+
if not exists_on_disk:
|
|
566
|
+
return None
|
|
567
|
+
elapsed_label = _format_elapsed(elapsed)
|
|
568
|
+
return (
|
|
569
|
+
f"[deft] Last session: {last_active} (branch: {sentinel.last_branch}), "
|
|
570
|
+
f"{elapsed_label} ago. Resume? Run `task vbrief:show {last_active}`."
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _format_elapsed(delta: timedelta) -> str:
|
|
575
|
+
"""Render a positive :class:`timedelta` as ``<N>h`` or ``<N>m``.
|
|
576
|
+
|
|
577
|
+
Hours win over minutes once the delta crosses one hour -- the
|
|
578
|
+
resume nudge gate requires >= 2h so the minute spelling is only
|
|
579
|
+
used by future surfaces that lower the threshold; today it is the
|
|
580
|
+
safe fallback for sub-hour deltas should the caller invoke this
|
|
581
|
+
helper directly.
|
|
582
|
+
"""
|
|
583
|
+
total_seconds = int(delta.total_seconds())
|
|
584
|
+
if total_seconds < 3600:
|
|
585
|
+
minutes = max(total_seconds // 60, 1)
|
|
586
|
+
return f"{minutes}m"
|
|
587
|
+
hours = total_seconds // 3600
|
|
588
|
+
return f"{hours}h"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
__all__ = [
|
|
592
|
+
"ACTIVE_VBRIEF_PREFIX",
|
|
593
|
+
"MIN_RESUME_AGE",
|
|
594
|
+
"RITUAL_STATE_RELPATH",
|
|
595
|
+
"RITUAL_STATE_SCHEMA_VERSION",
|
|
596
|
+
"SCHEMA_VERSION",
|
|
597
|
+
"SENTINEL_RELPATH",
|
|
598
|
+
"RitualState",
|
|
599
|
+
"Sentinel",
|
|
600
|
+
"compute_resume_signal",
|
|
601
|
+
"new_ritual_state_payload",
|
|
602
|
+
"read",
|
|
603
|
+
"read_ritual_state",
|
|
604
|
+
"record_ritual_step",
|
|
605
|
+
"ritual_state_path",
|
|
606
|
+
"ritual_step",
|
|
607
|
+
"write",
|
|
608
|
+
"write_ritual_state",
|
|
609
|
+
]
|