@deftai/directive-content 0.58.0 → 0.60.0

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