Skip to content

Commit c8305de

Browse files
chore(hooks): vendor spec-status-integrity from attune-ai canonical (#15)
Re-vendors _state.py, spec_orient.py, and new spec_audit.py from the attune-ai canonical (plugin/hooks/) so this layer's .claude/hooks/ byte-match. Registers spec_audit.py in the Makefile HOOK_FILES list and refreshes .claude/hooks/.canonical-sha256 (via make sync-hooks) so the hook-drift guard passes. Source: attune-ai#933. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 78524b5 commit c8305de

5 files changed

Lines changed: 476 additions & 12 deletions

File tree

.claude/hooks/.canonical-sha256

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

.claude/hooks/_state.py

Lines changed: 281 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,80 @@ def _leading_verdict(status: str) -> str:
103103
return m.group(0).lower() if m else ""
104104

105105

106+
# ── Deliverables block (spec-status-integrity, DECIDE-3) ──────
107+
#
108+
# A machine-readable "## Deliverables" section names the paths/globs
109+
# (and optional symbols) a spec ships. The staleness classifier checks
110+
# them for existence on disk so a spec whose work shipped but whose
111+
# Status line still says draft/approved can be flagged. Grammar lives
112+
# in the spec's design.md §"Data contract".
113+
_DELIVERABLES_HEADING = re.compile(
114+
r"^##\s+Deliverables\s*$",
115+
re.IGNORECASE | re.MULTILINE,
116+
)
117+
# One deliverable: ``- <path-or-glob>`` with an optional
118+
# `` — symbol: <name>`` (em-dash or ``--``) suffix; the path may be
119+
# backtick-wrapped. The pattern is non-greedy so the symbol suffix is
120+
# split off rather than swallowed.
121+
_DELIVERABLE_ITEM = re.compile(
122+
r"^\s*-\s+"
123+
r"`?(?P<pattern>[^`\s][^`]*?)`?"
124+
r"(?:\s+(?:—|--)\s*symbol:\s*(?P<symbol>\S+))?"
125+
r"\s*$",
126+
)
127+
# A lone ``- N/A`` / ``- None`` item ⇒ docs-only sentinel. Tolerant of
128+
# italic/backtick wrappers (``_None_``) and a trailing period/ellipsis.
129+
_DELIVERABLE_NA = re.compile(
130+
r"^\s*-\s+[_*`]*\s*(?:N/?A|None)\s*(?:\.{1,3})?\s*[_*`]*\s*$",
131+
re.IGNORECASE,
132+
)
133+
# Opt-out line, matched anywhere in the file (mirrors the terminal-line
134+
# anywhere-scan): a deliberate long-lived draft suppresses the check.
135+
_DRIFT_OPT_OUT = re.compile(
136+
r"^\s*drift-check:\s*ignore\s*$",
137+
re.IGNORECASE | re.MULTILINE,
138+
)
139+
# Glob metacharacters — a deliverable pattern containing any of these is
140+
# resolved by splitting off the literal-prefix dir and globbing the tail
141+
# inside it (pathlib ``**`` does not recurse into symlinked dirs
142+
# reliably, so we glob from the already-symlink-followed prefix).
143+
_GLOB_CHARS = re.compile(r"[*?\[]")
144+
145+
106146
# Sentinel TTL: anything older than this on a SessionStart prune
107147
# sweep is considered orphaned from an ungraceful exit.
108148
_SENTINEL_TTL_SECONDS = 7 * 24 * 60 * 60
109149

110150

151+
@dataclass(frozen=True)
152+
class DeliverableEntry:
153+
"""One declared deliverable from a spec's ``## Deliverables`` block.
154+
155+
``pattern`` is a repo-prefixed path or glob (e.g.
156+
``attune-ai/plugin/hooks/spec_audit.py`` or
157+
``attune-gui/tests/**/test_bar.py``). ``symbol`` is an optional
158+
grep target (function / class name) that must also be present in a
159+
matched file before the entry counts as resolved.
160+
"""
161+
162+
pattern: str
163+
symbol: str = ""
164+
165+
166+
@dataclass(frozen=True)
167+
class DeliverableSpec:
168+
"""Parsed ``## Deliverables`` contract for one spec.
169+
170+
``is_na`` marks the docs-only sentinel (a lone ``- N/A`` item);
171+
``opt_out`` marks a deliberate ``drift-check: ignore`` suppression.
172+
Both keep a spec out of ``suspected-stale`` without silent magic.
173+
"""
174+
175+
entries: tuple[DeliverableEntry, ...] = ()
176+
is_na: bool = False
177+
opt_out: bool = False
178+
179+
111180
@dataclass(frozen=True)
112181
class SpecInfo:
113182
"""One in-flight spec discovered under a workspace root."""
@@ -154,6 +223,18 @@ class SpecInfo:
154223
``status`` (i.e. header drifted away from completion state).
155224
spec_orient renders a one-line hint when True."""
156225

226+
# spec-status-integrity additions (2026-06-17). Optional with safe
227+
# defaults so existing positional/keyword constructors don't break
228+
# (same discipline as the 2026-06-02 self-truthing fields above).
229+
deliverables: tuple[DeliverableEntry, ...] = ()
230+
"""Deliverables parsed from the chosen phase file's
231+
``## Deliverables`` block; empty when none are declared."""
232+
233+
staleness: str = "unknown"
234+
"""Staleness verdict from ``classify_staleness`` — one of
235+
``ok`` / ``suspected-stale`` / ``unknown`` / ``partial`` /
236+
``docs-only`` / ``opted-out``. See DECIDE-3..5."""
237+
157238

158239
@dataclass(frozen=True)
159240
class GitState:
@@ -247,6 +328,50 @@ def _completion_signal(text: str) -> tuple[str | None, str]:
247328
return None, "header"
248329

249330

331+
def deliverables_for_spec(text: str) -> DeliverableSpec:
332+
"""Parse a spec's ``## Deliverables`` block into a contract.
333+
334+
Same regex-driven style as ``_completion_signal``. The section is
335+
bounded by the ``## Deliverables`` heading and the next ``## `` (via
336+
``_NEXT_H2``). Each list item becomes a ``DeliverableEntry``; a lone
337+
``- N/A`` item sets ``is_na``; a ``drift-check: ignore`` line found
338+
anywhere in the file sets ``opt_out``.
339+
340+
A malformed or empty block yields zero entries (so the classifier
341+
reports ``unknown``, never a false ``suspected-stale``). The opt-out
342+
scan is independent of the section, mirroring the terminal-line scan.
343+
"""
344+
opt_out = bool(_DRIFT_OPT_OUT.search(text))
345+
346+
heading = _DELIVERABLES_HEADING.search(text)
347+
if heading is None:
348+
return DeliverableSpec(opt_out=opt_out)
349+
350+
section_start = heading.end()
351+
next_heading = _NEXT_H2.search(text, section_start)
352+
section_end = next_heading.start() if next_heading else len(text)
353+
section = text[section_start:section_end]
354+
355+
entries: list[DeliverableEntry] = []
356+
is_na = False
357+
for line in section.splitlines():
358+
if not line.lstrip().startswith("-"):
359+
continue
360+
if _DELIVERABLE_NA.match(line):
361+
is_na = True
362+
continue
363+
match = _DELIVERABLE_ITEM.match(line)
364+
if match is None:
365+
continue
366+
pattern = match.group("pattern").strip()
367+
if not pattern:
368+
continue
369+
symbol = (match.group("symbol") or "").strip()
370+
entries.append(DeliverableEntry(pattern=pattern, symbol=symbol))
371+
372+
return DeliverableSpec(entries=tuple(entries), is_na=is_na, opt_out=opt_out)
373+
374+
250375
def _reconcile_status(header_status: str, phase_text: str) -> tuple[str, str, bool]:
251376
"""Reconcile header status against completion signals.
252377
@@ -269,13 +394,140 @@ def _reconcile_status(header_status: str, phase_text: str) -> tuple[str, str, bo
269394
return verdict, source, not header_is_terminal
270395

271396

397+
def _split_glob(pattern: str) -> tuple[str, str]:
398+
"""Split a path-glob into ``(literal_prefix, glob_tail)``.
399+
400+
The literal prefix is the leading run of ``/``-separated segments
401+
that contain no glob metacharacters; the tail is the remainder
402+
(which begins at the first segment that does). A plain path with no
403+
glob yields an empty tail. Splitting lets the caller resolve the
404+
prefix directory (following any symlink) and then glob the tail
405+
*inside* the real directory — avoiding pathlib's unreliable
406+
``**``-across-symlink recursion (D-4).
407+
"""
408+
parts = pattern.split("/")
409+
literal: list[str] = []
410+
for i, part in enumerate(parts):
411+
if _GLOB_CHARS.search(part):
412+
return "/".join(literal), "/".join(parts[i:])
413+
literal.append(part)
414+
return "/".join(literal), ""
415+
416+
417+
def _symbol_present(files: list[Path], symbol: str) -> bool:
418+
"""True if ``symbol`` appears as a substring in any of ``files``.
419+
420+
v1 uses plain substring matching (decisions.md "Open" — AST-accurate
421+
detection is out of scope). Unreadable files are skipped.
422+
"""
423+
for fpath in files:
424+
try:
425+
content = fpath.read_text(encoding="utf-8", errors="replace")
426+
except OSError:
427+
continue
428+
if symbol in content:
429+
return True
430+
return False
431+
432+
433+
def _resolve_entry(entry: DeliverableEntry, roots: list[Path]) -> bool:
434+
"""True if a deliverable resolves to an on-disk file under any root.
435+
436+
Resolution semantics (D-4):
437+
438+
- The pattern is repo-prefixed (``attune-rag/src/...``) and every
439+
layer is a symlink under the workspace root, so ``root / pattern``
440+
follows the symlink without ``.resolve()`` (which would escape the
441+
root — see ``reference_sibling_editable_venvs``).
442+
- Globs are split into a literal prefix + tail; the prefix dir is
443+
resolved first, then the tail is globbed inside it.
444+
- When ``entry.symbol`` is set, at least one matched file must also
445+
contain the symbol (substring grep) for the entry to resolve.
446+
"""
447+
prefix, tail = _split_glob(entry.pattern)
448+
for root in roots:
449+
matched: list[Path] = []
450+
if tail:
451+
base = root / prefix if prefix else root
452+
try:
453+
if not base.is_dir():
454+
continue
455+
matched = [p for p in base.glob(tail) if p.is_file()]
456+
except OSError:
457+
continue
458+
else:
459+
candidate = root / entry.pattern
460+
try:
461+
if not candidate.exists():
462+
continue
463+
if candidate.is_file():
464+
matched = [candidate]
465+
else:
466+
# Directory deliverable — existence is the signal; a
467+
# symbol grep over a directory is meaningless.
468+
if not entry.symbol:
469+
return True
470+
continue
471+
except OSError:
472+
continue
473+
if not matched:
474+
continue
475+
if entry.symbol:
476+
if _symbol_present(matched, entry.symbol):
477+
return True
478+
continue
479+
return True
480+
return False
481+
482+
483+
def classify_staleness(spec_text: str, header_status: str, roots: list[Path]) -> str:
484+
"""Classify a spec's staleness from its declared deliverables.
485+
486+
Returns one of (design.md §"Classifier", D-5 — require ALL):
487+
488+
- ``opted-out`` — a ``drift-check: ignore`` line is present.
489+
- ``docs-only`` — the ``- N/A`` docs-only sentinel.
490+
- ``unknown`` — no Deliverables block, zero parseable
491+
entries, or entries declared but none present yet (genuinely
492+
pre-implementation — no actionable signal).
493+
- ``partial`` — at least one entry resolves, but not all
494+
(mid-implementation; never flagged, to keep the warning quiet).
495+
- ``suspected-stale`` — ALL entries resolve AND the (reconciled)
496+
status is still non-terminal: work shipped, status didn't.
497+
- ``ok`` — all entries resolve AND the status is
498+
already terminal/ongoing.
499+
"""
500+
spec = deliverables_for_spec(spec_text)
501+
if spec.opt_out:
502+
return "opted-out"
503+
if spec.is_na:
504+
return "docs-only"
505+
if not spec.entries:
506+
return "unknown"
507+
508+
resolved = sum(1 for entry in spec.entries if _resolve_entry(entry, roots))
509+
if resolved == 0:
510+
return "unknown"
511+
if resolved < len(spec.entries):
512+
return "partial"
513+
514+
# Every declared deliverable resolves. Reconcile the header against
515+
# any in-body terminal signal (DECIDE-1) so a spec already marked
516+
# done deeper in the file is not falsely flagged.
517+
effective, _source, _conflict = _reconcile_status(header_status, spec_text)
518+
lead = _leading_verdict(effective)
519+
if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS:
520+
return "ok"
521+
return "suspected-stale"
522+
523+
272524
def _phase_for_dir(
273525
spec_dir: Path,
274-
) -> tuple[str, str, str, str, bool, float] | None:
526+
) -> tuple[str, str, str, str, bool, str, float] | None:
275527
"""Pick the highest-priority phase file present in a spec dir.
276528
277529
Returns ``(phase, raw_status, effective_status, status_source,
278-
status_conflict, mtime)``:
530+
status_conflict, phase_text, mtime)``:
279531
280532
- ``phase`` — ``requirements`` / ``design`` / ``tasks``
281533
- ``raw_status`` — verbatim header status, lowercased
@@ -285,12 +537,15 @@ def _phase_for_dir(
285537
``"terminal-line"``
286538
- ``status_conflict`` — True when ``effective_status`` overrode
287539
a non-terminal ``raw_status``
540+
- ``phase_text`` — full text of the chosen phase file, so the
541+
caller can parse the Deliverables block and classify staleness
542+
without re-reading from disk
288543
- ``mtime`` — most recent across all phase files (fresh-file
289544
bumps the spec to the top of the list)
290545
291546
Returns ``None`` when no phase file is readable.
292547
"""
293-
chosen: tuple[str, str, str, str, bool] | None = None
548+
chosen: tuple[str, str, str, str, bool, str] | None = None
294549
latest_mtime = 0.0
295550
for phase, fname in _PHASE_FILES:
296551
fpath = spec_dir / fname
@@ -305,10 +560,10 @@ def _phase_for_dir(
305560
if chosen is None:
306561
raw_status, phase_text = _read_phase(fpath)
307562
effective, source, conflict = _reconcile_status(raw_status, phase_text)
308-
chosen = (phase, raw_status, effective, source, conflict)
563+
chosen = (phase, raw_status, effective, source, conflict, phase_text)
309564
if chosen is None:
310565
return None
311-
return chosen[0], chosen[1], chosen[2], chosen[3], chosen[4], latest_mtime
566+
return (*chosen, latest_mtime)
312567

313568

314569
def _is_in_flight(phase: str, effective_status: str) -> bool:
@@ -360,17 +615,22 @@ def _layer_for(roots: list[Path], base: Path) -> str:
360615
_SPEC_SUBDIRS: tuple[str, ...] = ("specs", "docs/specs")
361616

362617

363-
def discover_specs(roots: list[Path]) -> list[SpecInfo]:
618+
def discover_specs(roots: list[Path], include_terminal: bool = False) -> list[SpecInfo]:
364619
"""Walk ``specs/`` directories under each root for in-flight specs.
365620
366621
Args:
367622
roots: Workspace roots to scan. Each root is checked for a
368623
top-level ``specs/`` and for ``<root>/<layer>/specs/``
369624
directories (one nested level only — no recursive walk).
625+
include_terminal: When False (default), terminal/ongoing specs
626+
are excluded — the in-flight-only view ``spec_orient`` wants.
627+
When True, every spec is returned with its staleness verdict
628+
populated — the full table ``spec_audit`` prints.
370629
371630
Returns:
372631
``SpecInfo`` list, most-recently modified first. Tolerates
373-
missing dirs and malformed status lines.
632+
missing dirs and malformed status lines. Each result carries its
633+
parsed ``deliverables`` and ``staleness`` verdict.
374634
"""
375635
found: list[SpecInfo] = []
376636
seen: set[Path] = set()
@@ -401,9 +661,19 @@ def discover_specs(roots: list[Path]) -> list[SpecInfo]:
401661
phase_info = _phase_for_dir(spec_dir)
402662
if phase_info is None:
403663
continue
404-
phase, raw_status, effective, source, conflict, mtime = phase_info
405-
if not _is_in_flight(phase, effective):
664+
(
665+
phase,
666+
raw_status,
667+
effective,
668+
source,
669+
conflict,
670+
phase_text,
671+
mtime,
672+
) = phase_info
673+
if not include_terminal and not _is_in_flight(phase, effective):
406674
continue
675+
deliverable_spec = deliverables_for_spec(phase_text)
676+
staleness = classify_staleness(phase_text, raw_status, roots)
407677
found.append(
408678
SpecInfo(
409679
slug=spec_dir.name,
@@ -415,6 +685,8 @@ def discover_specs(roots: list[Path]) -> list[SpecInfo]:
415685
effective_status=effective,
416686
status_source=source,
417687
status_conflict=conflict,
688+
deliverables=deliverable_spec.entries,
689+
staleness=staleness,
418690
)
419691
)
420692
found.sort(key=lambda s: s.mtime, reverse=True)

0 commit comments

Comments
 (0)