Skip to content

Commit e0502e1

Browse files
chore(hooks): re-sync canonical hooks (attune-ai 10.3.0) + spec-status-reminder caller (#97)
spec-status-integrity task 7: sync-hooks updates _state.py / spec_audit.py / spec_orient.py / _sdk_gate.py; thin caller workflow delegates to the reusable spec-status-reminder in attune-ai. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bc1dbfc commit e0502e1

5 files changed

Lines changed: 596 additions & 36 deletions

File tree

.claude/hooks/.canonical-sha256

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
cfd43f72b3f64bde6cb779703eb13ea6dd2c55ea5ae3dace654bfa95e17345c9 security_guard.py
22
37ee358245e8be80b00517c32d586449cb669d6d6e02526cc37c0e6728c452d5 format_on_save.py
33
f06a2180e64db35f96bdb896fbbfa9bf0ebc5090744817f5b87a7f0fbbb7ec61 compact_warning.py
4-
931369475cda4f6a291ffec0fddcd99ec8eb84bef9bf2efaa46694d21ef6c740 spec_orient.py
5-
81d06cf240c219937f349b84bba18843b83b50625b9f649b3dbdd6f87b7c8fae _state.py
4+
c4437034774443b904e6c7cb529028ea521cf00e7a3f4859a2f964aadf08ffbc spec_orient.py
5+
306d4d68e8e28d09f2ce102c8df6e234c2e4175b1b0c7ccf3dee6f26444584fe _state.py
66
63293f305ff32aab46d1da8b9d28c71ce39b658d2a8572c64024614abdf7dffe _resume_prompt.py
77
baa145fb6fac25ae7d03a5b655b04aba25bfb77793dcdcaf44acc151394f030b _transcript_size.py
88
48674de791f509c539417b29214d9c87a33b7934b985597af79711ddd90ea17a _sdk_gate.py
9-
8818f37208b6879940f6b4e43dd368f63ff3322728cdeb06bc68c073f649b345 spec_audit.py
9+
7145a707f6e14473f71e738e3c50df059bf1ad02b3b3b9fa13e5e8b4bc247e72 spec_audit.py

.claude/hooks/_state.py

Lines changed: 222 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,57 @@
2121

2222
from __future__ import annotations
2323

24+
import json
2425
import os
2526
import re
2627
import subprocess
2728
import time
2829
from dataclasses import dataclass
2930
from pathlib import Path
3031

32+
# ── Drift-cache read (spec-status-integrity, design §3) ───────
33+
#
34+
# ``spec_audit.py --pr-links`` writes ``.attune/spec-drift.json`` per
35+
# workspace root; ``spec_orient`` reads it back so the SessionStart
36+
# annotation stays offline (the hook's no-network invariant). Entries
37+
# older than one weekly-CI period + slack are ignored.
38+
_DRIFT_CACHE_MAX_AGE_SECONDS = 8 * 24 * 60 * 60
39+
40+
41+
def read_drift_cache(roots: list[Path], now: float | None = None) -> dict[str, dict]:
42+
"""Merge fresh drift-cache entries across workspace roots.
43+
44+
Returns ``{"<layer>/<slug>": {verdict, prs, signal}}`` from every
45+
root whose ``.attune/spec-drift.json`` is present, well-formed, and
46+
fresher than :data:`_DRIFT_CACHE_MAX_AGE_SECONDS`. Anything else —
47+
absent file, malformed JSON, wrong shapes, stale ``generated_at`` —
48+
contributes nothing and never raises (the cache is advisory; the
49+
annotation simply falls back to current behavior).
50+
"""
51+
current = time.time() if now is None else now
52+
merged: dict[str, dict] = {}
53+
for root in roots:
54+
cache_path = Path(root) / ".attune" / "spec-drift.json"
55+
try:
56+
data = json.loads(cache_path.read_text(encoding="utf-8"))
57+
except (OSError, json.JSONDecodeError, ValueError, UnicodeDecodeError):
58+
continue
59+
if not isinstance(data, dict):
60+
continue
61+
generated = data.get("generated_at")
62+
if not isinstance(generated, int | float):
63+
continue
64+
if current - float(generated) > _DRIFT_CACHE_MAX_AGE_SECONDS:
65+
continue
66+
specs = data.get("specs")
67+
if not isinstance(specs, dict):
68+
continue
69+
for key, entry in specs.items():
70+
if isinstance(key, str) and isinstance(entry, dict):
71+
merged.setdefault(key, entry)
72+
return merged
73+
74+
3175
# Phases checked, highest-priority first. The first phase file
3276
# present in a spec directory determines the displayed phase
3377
# and status.
@@ -59,7 +103,7 @@
59103
# done (see decisions.md DECIDE-2).
60104
_TERMINAL_LINE = re.compile(
61105
r"^\s*\**\s*(?:Spec\s+)?Status\**\s*:\s*\**\s*"
62-
r"(closed|complete|completed|retired|superseded|shipped|done)\b",
106+
r"(closed|complete|completed|retired|superseded|shipped|done|implemented)\b",
63107
re.IGNORECASE | re.MULTILINE,
64108
)
65109
_CHECKLIST_HEADING = re.compile(
@@ -76,12 +120,30 @@
76120
)
77121
_NEXT_H2 = re.compile(r"^##\s+", re.MULTILINE)
78122
_TERMINAL_VERDICTS = frozenset(
79-
{"closed", "complete", "completed", "retired", "superseded", "shipped", "done"}
123+
{
124+
"closed",
125+
"complete",
126+
"completed",
127+
"retired",
128+
"superseded",
129+
"shipped",
130+
"done",
131+
# spec-status-integrity additions (design §1): ``implemented``
132+
# and the bare check glyphs are terminal — the wild-scan found
133+
# both in real headers.
134+
"implemented",
135+
"✓",
136+
"✅",
137+
}
80138
)
81139
# Ongoing-by-design statuses: a living roadmap / continuous program is not
82140
# pending work, so it should NOT show as in-flight — but it is also not
83141
# "done". Excluded from the in-flight list, kept distinct from terminal.
84142
_ONGOING_VERDICTS = frozenset({"living", "ongoing"})
143+
# Parked-semantics statuses (spec-status-integrity, design §1): work
144+
# deliberately on hold. Skipped by drift checks and excluded from the
145+
# in-flight orientation list; the audit still lists them (as parked).
146+
_PARKED_VERDICTS = frozenset({"parked", "paused", "blocked", "deferred"})
85147

86148
# Leading alphabetic word of a status value, for first-word tokenization:
87149
# ``complete (2026-06-09) — shipped #694`` -> ``complete``.
@@ -96,13 +158,164 @@ def _leading_verdict(status: str) -> str:
96158
not exact-string membership. This is the fix for the class of bug
97159
where a correctly-marked-``complete`` spec stayed in-flight forever
98160
because ``"complete (date) — ..."`` is not in ``_TERMINAL_VERDICTS``.
161+
162+
A bare check glyph (``✓`` / ``✅``) leading the value is returned
163+
as-is — the glyphs are members of ``_TERMINAL_VERDICTS``
164+
(spec-status-integrity, design §1) but are not alphabetic, so the
165+
word regex would otherwise skip past them to whatever word follows.
99166
"""
167+
stripped = status.strip().lstrip("*_`").strip()
168+
for glyph in ("✅", "✓"):
169+
if stripped.startswith(glyph):
170+
return glyph
100171
# search (not match) so a stray leading ``**``/punctuation doesn't
101172
# swallow the verdict — the first alphabetic run is the word we want.
102173
m = _LEADING_WORD.search(status)
103174
return m.group(0).lower() if m else ""
104175

105176

177+
# ── Status-vocabulary lint (spec-status-integrity, design §1) ─
178+
#
179+
# The canonical vocabulary for NEW specs. Historical aliases
180+
# (``_TERMINAL_VERDICTS`` / ``_ONGOING_VERDICTS`` members and the
181+
# in-flight forms below) stay accepted — no mass rewrite of ~127
182+
# existing status lines — but the lint steers authors to these 8.
183+
_CANONICAL_STATUS_TOKENS = (
184+
"draft",
185+
"in-review",
186+
"approved",
187+
"in-progress",
188+
"implemented",
189+
"complete",
190+
"superseded",
191+
"parked",
192+
)
193+
# In-flight forms honored by the checker (design §2): the canonical
194+
# in-flight 4 plus the historical ``not started`` / ``open`` / ``pending``.
195+
# ``not`` is the leading token of ``not started``.
196+
_IN_FLIGHT_TOKENS = frozenset(
197+
{"draft", "in-review", "approved", "in-progress", "not", "open", "pending"}
198+
)
199+
# Lint tokenization keeps hyphens (``in-review``), unlike
200+
# ``_LEADING_WORD`` which stops at the first non-alpha char.
201+
_LINT_TOKEN = re.compile(r"[A-Za-z][A-Za-z-]*")
202+
203+
204+
def lint_status_token(status: str) -> str | None:
205+
"""Lint a status value's leading token against the known vocabulary.
206+
207+
Returns ``None`` when the leading token is recognized — one of the
208+
canonical 8, a historical terminal/ongoing alias, a parked-family
209+
token, an accepted in-flight form, or a bare check glyph. Otherwise
210+
returns a one-line ``unparseable`` message naming the canonical 8;
211+
the token is never guessed at.
212+
"""
213+
stripped = status.strip().lstrip("*_`").strip()
214+
for glyph in ("✅", "✓"):
215+
if stripped.startswith(glyph):
216+
return None
217+
match = _LINT_TOKEN.search(status)
218+
token = match.group(0).lower() if match else ""
219+
recognized = (
220+
_TERMINAL_VERDICTS | _ONGOING_VERDICTS | _PARKED_VERDICTS | _IN_FLIGHT_TOKENS
221+
) | set(_CANONICAL_STATUS_TOKENS)
222+
if token in recognized:
223+
return None
224+
shown = token or status.strip() or "(empty)"
225+
return f'unparseable status "{shown}" — use one of: ' + ", ".join(_CANONICAL_STATUS_TOKENS)
226+
227+
228+
# ── PR-reference extraction (spec-status-integrity, design §1) ──
229+
#
230+
# ``extract_pr_refs()`` parses the four PR-citation styles found in the
231+
# wild (workspace spec design.md §1): explicit ``PR #212`` /
232+
# ``PRs #303, #304`` lists, bare ``#1191`` (ambiguous — may be an
233+
# issue; resolved at check time via the pulls API, merged-only), and
234+
# the markdown pull-URL — the required style for cross-repo refs.
235+
# Code fences and inline code spans are blanked first so quoted
236+
# examples (`` `#NNN` `` in docs) never count as citations.
237+
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
238+
_INLINE_CODE = re.compile(r"`[^`\n]+`")
239+
_PULL_URL = re.compile(r"https://github\.com/(?P<repo>[\w.-]+/[\w.-]+)/pull/(?P<number>\d+)\b")
240+
# Whole markdown links are blanked AFTER pull-URLs are extracted, so a
241+
# link text like ``[#95](…/pull/95)`` doesn't ALSO scan as a bare
242+
# current-repo ref — and issue-links yield nothing at all.
243+
_MD_LINK = re.compile(r"\[[^\]\n]*\]\([^)\n]*\)")
244+
_PR_LIST = re.compile(r"\bPRs?\s*#\d+(?:\s*,\s*#\d+)*", re.IGNORECASE)
245+
_REF_NUMBER = re.compile(r"#(\d+)")
246+
# Bare ``#NNN``: not preceded by a word char / ``#`` / ``/`` (rejects
247+
# ``foo#12``, anchors, URL paths) and not followed by a word char
248+
# (rejects ``#12abc``).
249+
_BARE_REF = re.compile(r"(?<![\w#/])#(\d+)(?![\w#])")
250+
251+
252+
@dataclass(frozen=True)
253+
class PrRef:
254+
"""One PR citation extracted from a spec's text.
255+
256+
``repo`` is the explicit ``owner/name`` slug from a pull-URL
257+
citation; ``None`` means "current repo". ``explicit`` is True when
258+
the citation is unambiguously a PR (``PR #N`` / ``PRs #N, …`` /
259+
pull-URL); a bare ``#NNN`` is ``explicit=False`` — it may be an
260+
issue, which the checker resolves via the pulls API at check time.
261+
"""
262+
263+
number: int
264+
repo: str | None = None
265+
explicit: bool = False
266+
267+
268+
def _blank(match: re.Match[str]) -> str:
269+
"""Length-preserving mask so match positions stay comparable."""
270+
return " " * (match.end() - match.start())
271+
272+
273+
def extract_pr_refs(text: str) -> list[PrRef]:
274+
"""Extract PR citations from spec text — deduped, document order.
275+
276+
Styles recognized (workspace design §1): ``PR #212``,
277+
``PRs #303, #304``, bare ``#1191``, and
278+
``https://github.com/<owner>/<repo>/pull/<n>`` (markdown-wrapped or
279+
bare). Duplicate ``(repo, number)`` pairs collapse to the earliest
280+
occurrence, upgraded to ``explicit`` if any occurrence was.
281+
"""
282+
scrubbed = _CODE_FENCE.sub(_blank, text)
283+
scrubbed = _INLINE_CODE.sub(_blank, scrubbed)
284+
285+
hits: list[tuple[int, str | None, int, bool]] = [] # (pos, repo, number, explicit)
286+
287+
for match in _PULL_URL.finditer(scrubbed):
288+
hits.append((match.start(), match.group("repo"), int(match.group("number")), True))
289+
# Blank markdown links wholesale (pull-URLs already harvested), then
290+
# any bare pull-URLs outside links, before the plain-text scans.
291+
scrubbed = _MD_LINK.sub(_blank, scrubbed)
292+
scrubbed = _PULL_URL.sub(_blank, scrubbed)
293+
294+
for match in _PR_LIST.finditer(scrubbed):
295+
for num in _REF_NUMBER.finditer(match.group(0)):
296+
hits.append((match.start() + num.start(), None, int(num.group(1)), True))
297+
scrubbed = _PR_LIST.sub(_blank, scrubbed)
298+
299+
for match in _BARE_REF.finditer(scrubbed):
300+
hits.append((match.start(), None, int(match.group(1)), False))
301+
302+
# Dedupe on (repo, number): earliest position wins the slot; the
303+
# explicit flag is OR-merged across occurrences.
304+
best: dict[tuple[str | None, int], tuple[int, bool]] = {}
305+
for pos, repo, number, explicit in hits:
306+
key = (repo, number)
307+
if key in best:
308+
prev_pos, prev_explicit = best[key]
309+
best[key] = (prev_pos, prev_explicit or explicit)
310+
else:
311+
best[key] = (pos, explicit)
312+
ordered = sorted(best.items(), key=lambda item: item[1][0])
313+
return [
314+
PrRef(number=number, repo=repo, explicit=explicit)
315+
for (repo, number), (_pos, explicit) in ordered
316+
]
317+
318+
106319
# ── Deliverables block (spec-status-integrity, DECIDE-3) ──────
107320
#
108321
# A machine-readable "## Deliverables" section names the paths/globs
@@ -516,7 +729,9 @@ def classify_staleness(spec_text: str, header_status: str, roots: list[Path]) ->
516729
# done deeper in the file is not falsely flagged.
517730
effective, _source, _conflict = _reconcile_status(header_status, spec_text)
518731
lead = _leading_verdict(effective)
519-
if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS:
732+
# Parked-family statuses are deliberately on hold — skipped by drift
733+
# checks (design §1), so they classify ``ok`` alongside terminal.
734+
if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS or lead in _PARKED_VERDICTS:
520735
return "ok"
521736
return "suspected-stale"
522737

@@ -577,6 +792,9 @@ def _is_in_flight(phase: str, effective_status: str) -> bool:
577792
phase.
578793
- First word is ongoing-by-design (living / ongoing) → a continuous
579794
program / living roadmap is not pending work, exclude.
795+
- First word is parked-family (parked / paused / blocked /
796+
deferred) → deliberately on hold, not pending work, exclude
797+
(spec-status-integrity, design §1).
580798
- Empty status (malformed) → still in-flight (don't drop a
581799
working spec because the heading was malformed).
582800
- Anything else (draft / approved / in-progress / …) → in-flight.
@@ -586,7 +804,7 @@ def _is_in_flight(phase: str, effective_status: str) -> bool:
586804
(the self-truthing improvement).
587805
"""
588806
lead = _leading_verdict(effective_status)
589-
if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS:
807+
if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS or lead in _PARKED_VERDICTS:
590808
return False
591809
return True
592810

0 commit comments

Comments
 (0)