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