@@ -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 )
112181class 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 )
159240class 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+
250375def _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+
272524def _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
314569def _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