@deftai/directive-content 0.55.2 → 0.56.0

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