2121
2222from __future__ import annotations
2323
24+ import json
2425import os
2526import re
2627import subprocess
2728import time
2829from dataclasses import dataclass
2930from 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.
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 (
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