aid-installer 1.1.0 → 2.0.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.
@@ -16,11 +16,11 @@
16
16
  # MIGRATION STATUS (task-013 M6 cutover audit):
17
17
  #
18
18
  # NORMALIZED (producer-emitted, feature-001 M1-M6 complete):
19
- # - Running -- M4: aid-interview, aid-specify, aid-plan, aid-detail,
19
+ # - Running -- M4: aid-describe, aid-define, aid-specify, aid-plan, aid-detail,
20
20
  # aid-execute, aid-deploy (state-idle.md) emit at phase entry.
21
21
  # - Paused-Awaiting-Input + Pause Reason -- M5: aid-specify (state-blocked.md,
22
22
  # state-spike.md), aid-execute (state-delivery-gate.md),
23
- # aid-interview (state-completion.md) emit on pause transitions.
23
+ # aid-describe (state-completion.md) emit on pause transitions.
24
24
  # - Blocked + Block Reason/Artifact -- M5: aid-execute (state-execute.md,
25
25
  # state-review.md), aid-execute (state-delivery-gate.md)
26
26
  # emit on impediment/failed-gate transitions.
@@ -60,7 +60,7 @@ from datetime import datetime, timezone
60
60
  from pathlib import Path
61
61
  from typing import Optional
62
62
 
63
- from .models import KbBaseline, KbStatus, Lifecycle, PendingInput, SourceMode, TaskModel, TaskStatus
63
+ from .models import DocFreshness, KbBaseline, KbStatus, Lifecycle, PendingInput, SourceMode, TaskModel, TaskStatus
64
64
 
65
65
 
66
66
  # ---------------------------------------------------------------------------
@@ -96,9 +96,10 @@ def _normalize_to_utc_ms(iso_str: str) -> Optional[int]:
96
96
  # FF-A2: git freshness check (task-064, LC-A2 reader subprocess)
97
97
  # ---------------------------------------------------------------------------
98
98
 
99
- # Allowed git verbs: ONLY rev-parse, symbolic-ref, log (read-only)
99
+ # Allowed git verbs: ONLY rev-parse, symbolic-ref, log, merge-base (read-only)
100
+ # merge-base added task-042 (f007): --is-ancestor comparison for per-doc freshness.
100
101
  # NEVER: fetch, pull, commit, checkout, reset, push, merge, rebase, add, rm
101
- _GIT_ALLOWED_VERBS = frozenset({"rev-parse", "symbolic-ref", "log"})
102
+ _GIT_ALLOWED_VERBS = frozenset({"rev-parse", "symbolic-ref", "log", "merge-base"})
102
103
 
103
104
  # Degradation timeout (seconds) -- bounded read, never blocks indefinitely
104
105
  _GIT_TIMEOUT_S = 2
@@ -367,6 +368,228 @@ def derive_kb_status(
367
368
  return KbStatus.unknown
368
369
 
369
370
 
371
+ # ---------------------------------------------------------------------------
372
+ # f007 / task-042: per-doc freshness read
373
+ # ---------------------------------------------------------------------------
374
+
375
+ def derive_doc_freshness(kb_dir: Path, repo_root: Path) -> list[DocFreshness]:
376
+ """f007: Per-doc freshness read for all hand-authored primary/extension KB docs.
377
+
378
+ Same algorithm as kb-freshness-check.sh (task-040):
379
+ - Same doc routing (skip INDEX.md, README.md, STATE.md, meta, generated)
380
+ - Same absence gate (no approved_at_commit: -> unknown; no/empty sources: -> current)
381
+ - Same git verbs: git-log -1 --format=%H -- <src> + merge-base --is-ancestor
382
+ - Same fold rule: suspect > current > unknown
383
+ - Same degrade-to-unknown matrix (any git failure -> unknown, never false suspect)
384
+
385
+ Returns list[DocFreshness] sorted by doc path (same deterministic order as script).
386
+ Never raises (NFR7). No writes. No LLM. Read-only git only.
387
+
388
+ merge-base is in _GIT_ALLOWED_VERBS (added task-042; still read-only).
389
+ """
390
+ from .parsers import parse_doc_frontmatter, is_url_source
391
+
392
+ results: list[DocFreshness] = []
393
+
394
+ if not kb_dir.is_dir():
395
+ return results
396
+
397
+ # Collect docs: skip INDEX.md, README.md, STATE.md + meta + generated
398
+ # (same routing as build-kb-index.sh / lint-frontmatter.sh / kb-freshness-check.sh)
399
+ try:
400
+ candidate_paths = sorted(
401
+ p for p in kb_dir.iterdir()
402
+ if p.is_file() and p.suffix == ".md" and not p.name.startswith(".")
403
+ )
404
+ except OSError:
405
+ return results
406
+
407
+ # Always-skipped names
408
+ _SKIP_NAMES = {"INDEX.md", "README.md", "STATE.md"}
409
+
410
+ for doc_path in candidate_paths:
411
+ if doc_path.name in _SKIP_NAMES:
412
+ continue
413
+
414
+ # Check routing fields: kb-category and source
415
+ # Re-use parse_doc_frontmatter but we need the routing fields too.
416
+ # Read routing separately via a quick line-scan (same tolerance posture).
417
+ kb_cat, src_field = _read_routing_fields(doc_path)
418
+
419
+ if kb_cat == "meta":
420
+ continue
421
+ if src_field == "generated":
422
+ continue
423
+ # Only primary and extension with hand-authored (or absent) source
424
+ if kb_cat not in ("primary", "extension", ""):
425
+ continue
426
+
427
+ rel = doc_path.name # relative path within kb_dir (top-level only)
428
+
429
+ # Parse frontmatter: approved_at_commit + sources
430
+ approved_at_commit, sources_list, sources_field_present = parse_doc_frontmatter(doc_path)
431
+
432
+ # Absence gate: missing/empty approved_at_commit -> unknown (never suspect)
433
+ if not approved_at_commit:
434
+ results.append(DocFreshness(doc=rel, verdict="unknown", suspect_sources=[]))
435
+ continue
436
+
437
+ # sources: absent or empty -> current (nothing to drift against)
438
+ if not sources_field_present or not sources_list:
439
+ results.append(DocFreshness(doc=rel, verdict="current", suspect_sources=[]))
440
+ continue
441
+
442
+ # Per-source staleness checks
443
+ n_current = 0
444
+ n_suspect = 0
445
+ n_unknown = 0
446
+ suspect_sources: list[str] = []
447
+
448
+ for entry in sources_list:
449
+ src_verdict = _check_source(entry, approved_at_commit, repo_root)
450
+ if src_verdict == "current":
451
+ n_current += 1
452
+ elif src_verdict == "suspect":
453
+ n_suspect += 1
454
+ suspect_sources.append(entry)
455
+ else:
456
+ n_unknown += 1
457
+
458
+ # Fold rule (identical to script)
459
+ if n_suspect > 0:
460
+ verdict = "suspect"
461
+ elif n_current > 0:
462
+ verdict = "current"
463
+ else:
464
+ verdict = "unknown"
465
+
466
+ results.append(DocFreshness(doc=rel, verdict=verdict, suspect_sources=suspect_sources))
467
+
468
+ return results
469
+
470
+
471
+ def _read_routing_fields(doc_path: Path) -> tuple[str, str]:
472
+ """Read kb-category and source frontmatter scalars for routing.
473
+
474
+ Returns (kb_category, source_field). Absent fields return "".
475
+ Same degrade posture: any failure -> ("", "") -> treated as primary/hand-authored.
476
+ Never raises.
477
+ """
478
+ try:
479
+ raw = doc_path.read_bytes()
480
+ text = raw.decode("utf-8", errors="replace")
481
+ except OSError:
482
+ return "", ""
483
+
484
+ kb_cat = ""
485
+ src_field = ""
486
+ in_fm = False
487
+ fm_entered = False
488
+
489
+ for line in text.splitlines():
490
+ stripped_line = line.rstrip()
491
+ if stripped_line == "---":
492
+ if not fm_entered:
493
+ in_fm = True
494
+ fm_entered = True
495
+ continue
496
+ else:
497
+ break
498
+ if not in_fm:
499
+ break
500
+
501
+ m = re.match(r"^kb-category:\s*(.*)", stripped_line)
502
+ if m:
503
+ kb_cat = m.group(1).strip().strip('"').strip("'")
504
+ continue
505
+
506
+ m = re.match(r"^source:\s*(.*)", stripped_line)
507
+ if m:
508
+ src_field = m.group(1).strip().strip('"').strip("'")
509
+ continue
510
+
511
+ return kb_cat, src_field
512
+
513
+
514
+ def _check_source(entry: str, approved_at_commit: str, repo_root: Path) -> str:
515
+ """Check one sources: entry against approved_at_commit.
516
+
517
+ Returns "current" | "suspect" | "unknown".
518
+ Same algorithm as check_source() in kb-freshness-check.sh:
519
+ - URL -> unknown (cannot git log a URL)
520
+ - Path/glob -> git log -1 --format=%H -- <entry>; empty -> unknown
521
+ - Compare with merge-base --is-ancestor C_src <approved_at_commit>
522
+ exit 0 -> current, exit 1 -> suspect, other -> unknown (never false suspect)
523
+
524
+ Uses the existing _GIT_TIMEOUT_S bound (same 2s limit as other git calls).
525
+ Never raises; any failure degrades to unknown.
526
+ """
527
+ from .parsers import is_url_source
528
+
529
+ # URL source -> unknown (cannot git log a URL)
530
+ if is_url_source(entry):
531
+ return "unknown"
532
+
533
+ # Path/glob source: get last-changed commit
534
+ c_src = _run_git_log_hash(repo_root, entry)
535
+ if c_src is None:
536
+ # Empty output (untracked / never existed) or git failure -> unknown
537
+ return "unknown"
538
+
539
+ # Compare: is C_src an ancestor of (or equal to) approved_at_commit?
540
+ return _run_merge_base_is_ancestor(repo_root, c_src, approved_at_commit)
541
+
542
+
543
+ def _run_git_log_hash(repo_root: Path, pathspec: str) -> Optional[str]:
544
+ """Run: git -C <repo_root> log -1 --format=%H -- <pathspec>
545
+
546
+ Returns the commit SHA on success, None on every failure mode.
547
+ Same subprocess pattern as _run_git_log (bounded, no-shell).
548
+ """
549
+ try:
550
+ result = subprocess.run(
551
+ ["git", "-C", str(repo_root), "log", "-1", "--format=%H", "--", pathspec],
552
+ capture_output=True,
553
+ text=True,
554
+ timeout=_GIT_TIMEOUT_S,
555
+ )
556
+ if result.returncode != 0:
557
+ return None
558
+ sha = result.stdout.strip()
559
+ return sha if sha else None
560
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
561
+ return None
562
+
563
+
564
+ def _run_merge_base_is_ancestor(repo_root: Path, c_src: str, baseline: str) -> str:
565
+ """Run: git -C <repo_root> merge-base --is-ancestor <c_src> <baseline>
566
+
567
+ Returns:
568
+ "current" -- exit 0 (c_src is ancestor of baseline; source unchanged since approval)
569
+ "suspect" -- exit 1 (c_src is NOT ancestor; source changed after approval)
570
+ "unknown" -- any other exit code (bad object, git absent, timeout, etc.)
571
+
572
+ Uses merge-base verb which is now in _GIT_ALLOWED_VERBS (task-042).
573
+ Never raises; unexpected exit codes degrade to unknown (never a false suspect).
574
+ """
575
+ try:
576
+ result = subprocess.run(
577
+ ["git", "-C", str(repo_root), "merge-base", "--is-ancestor", c_src, baseline],
578
+ capture_output=True,
579
+ text=True,
580
+ timeout=_GIT_TIMEOUT_S,
581
+ )
582
+ if result.returncode == 0:
583
+ return "current"
584
+ elif result.returncode == 1:
585
+ return "suspect"
586
+ else:
587
+ # exit 128 (bad object), etc. -> unknown (never a false suspect)
588
+ return "unknown"
589
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
590
+ return "unknown"
591
+
592
+
370
593
  # ---------------------------------------------------------------------------
371
594
  # SM-2: derive_lifecycle -- unified preferred + fallback entry point
372
595
  #
@@ -134,6 +134,21 @@ class KbBaseline:
134
134
  tip_date: Optional[str] = None
135
135
 
136
136
 
137
+ @dataclass
138
+ class DocFreshness:
139
+ """Per-doc freshness verdict (feature-007 f007, task-042).
140
+
141
+ Populated by derive_doc_freshness() in derivation.py; never persisted (NFR2).
142
+
143
+ doc: doc relative path under .aid/knowledge/ (e.g. "architecture.md")
144
+ verdict: "current" | "suspect" | "unknown"
145
+ suspect_sources: list of sources: entries that drifted (empty unless verdict=="suspect")
146
+ """
147
+ doc: str
148
+ verdict: str # "current" | "suspect" | "unknown"
149
+ suspect_sources: list[str] = field(default_factory=list)
150
+
151
+
137
152
  @dataclass
138
153
  class KbStateRef:
139
154
  """KB state reference (feature-007 DM-A1 extended KbStateRef).
@@ -150,6 +165,10 @@ class KbStateRef:
150
165
  status -- FR32 5-state KbStatus (derived, never persisted; NFR2)
151
166
  summary_present -- True if <repo>/.aid/dashboard/kb.html exists (stat only)
152
167
  kb_baseline -- {branch, tip_date} from .aid/settings.yml; None if unset/unparseable
168
+
169
+ New fields (feature-007 f007, task-042):
170
+ doc_freshness -- per-doc freshness list [{doc, verdict, suspect_sources}, ...]
171
+ suspect_count -- count of docs with verdict=="suspect" (badge rollup)
153
172
  """
154
173
  summary_approved: bool
155
174
  last_summary_date: Optional[str] = None # from STATE.md "User Approved: yes (YYYY-MM-DD...)"
@@ -158,6 +177,9 @@ class KbStateRef:
158
177
  status: KbStatus = KbStatus.unknown # FR32 5-state derived status
159
178
  summary_present: bool = False # True if .aid/dashboard/kb.html exists
160
179
  kb_baseline: Optional[KbBaseline] = None # {branch, tip_date} or None
180
+ # feature-007 f007 new fields (task-042):
181
+ doc_freshness: list[DocFreshness] = field(default_factory=list) # per-doc verdicts
182
+ suspect_count: int = 0 # count of suspect docs (badge rollup)
161
183
 
162
184
 
163
185
  @dataclass
@@ -21,6 +21,7 @@ from typing import Optional
21
21
  from .models import (
22
22
  DeliverableRef,
23
23
  DeferredIssue,
24
+ DocFreshness,
24
25
  FeatureRef,
25
26
  Finding,
26
27
  KbBaseline,
@@ -415,6 +416,132 @@ def _parse_kb_doc_count(text: str) -> Optional[int]:
415
416
  return count if in_completeness else None
416
417
 
417
418
 
419
+ # ---------------------------------------------------------------------------
420
+ # f007 / task-042: per-doc frontmatter scan (sources: + approved_at_commit:)
421
+ # ---------------------------------------------------------------------------
422
+
423
+ _RE_FM_FENCE = re.compile(r"^---\s*$")
424
+ _RE_URL = re.compile(r"^[a-z][a-z0-9+.\-]*://")
425
+
426
+
427
+ def parse_doc_frontmatter(path: Path) -> tuple[Optional[str], list[str], bool]:
428
+ """Tolerant sources:/approved_at_commit: frontmatter scan for one KB doc.
429
+
430
+ Reads only the YAML frontmatter block (between the first pair of '---' lines).
431
+ Identical algorithm to the bash fm_scalar/fm_list/fm_sources_present helpers in
432
+ kb-freshness-check.sh; mirrors the Node twin in reader.mjs (byte-parity).
433
+
434
+ Returns:
435
+ (approved_at_commit, sources_list, sources_field_present)
436
+
437
+ approved_at_commit:
438
+ the trimmed scalar value, or None if absent/empty.
439
+ sources_list:
440
+ items from the sources: YAML list (inline or block); empty list if field
441
+ is absent, sources: [], or the value is not a list.
442
+ sources_field_present:
443
+ True if the sources: key was present (even as sources: []), False if absent.
444
+ Used to distinguish "sources: []" (-> current) from "no sources: field" (-> current too,
445
+ but noted separately for debugging; both map to current per the SPEC).
446
+
447
+ Never raises (NFR7). Handles:
448
+ - No frontmatter (no leading ---) -> (None, [], False)
449
+ - Inline list: sources: [a, b]
450
+ - Block list: sources:\n - a\n - b
451
+ - Empty list: sources: [] -> (approval, [], True)
452
+ """
453
+ if not path.is_file():
454
+ return None, [], False
455
+
456
+ try:
457
+ raw = path.read_bytes()
458
+ text = raw.decode("utf-8", errors="replace")
459
+ except OSError:
460
+ return None, [], False
461
+
462
+ approved_at_commit: Optional[str] = None
463
+ sources_list: list[str] = []
464
+ sources_field_present: bool = False
465
+
466
+ in_fm = False
467
+ fm_entered = False
468
+ in_sources_block = False
469
+
470
+ for line in text.splitlines():
471
+ if _RE_FM_FENCE.match(line):
472
+ if not fm_entered:
473
+ # Opening fence
474
+ in_fm = True
475
+ fm_entered = True
476
+ continue
477
+ else:
478
+ # Closing fence
479
+ break
480
+
481
+ if not in_fm:
482
+ # No opening fence yet -- not in frontmatter (or no frontmatter)
483
+ break
484
+
485
+ # Inside frontmatter block
486
+ stripped = line.rstrip()
487
+
488
+ if in_sources_block:
489
+ # Continuation of a block-style sources: list
490
+ m_item = re.match(r"^[ \t]+-[ \t]*(.*)", stripped)
491
+ if m_item:
492
+ item = m_item.group(1).strip().strip('"').strip("'")
493
+ if item:
494
+ sources_list.append(item)
495
+ continue
496
+ else:
497
+ # Any non-item line ends the block
498
+ in_sources_block = False
499
+ # Fall through to check this line for other fields
500
+
501
+ # approved_at_commit: scalar
502
+ m_aac = re.match(r"^approved_at_commit:\s*(.*)", stripped)
503
+ if m_aac:
504
+ val = m_aac.group(1).strip().strip('"').strip("'")
505
+ approved_at_commit = val if val else None
506
+ continue
507
+
508
+ # sources: field
509
+ m_src = re.match(r"^sources:\s*(.*)", stripped)
510
+ if m_src:
511
+ sources_field_present = True
512
+ rest = m_src.group(1).strip()
513
+ if rest == "[]":
514
+ # Explicit empty inline list: sources: []
515
+ # sources_field_present already set; list stays []
516
+ continue
517
+ if not rest:
518
+ # Bare 'sources:' with nothing after -- block list follows
519
+ in_sources_block = True
520
+ continue
521
+ if rest.startswith("["):
522
+ # Inline list: [a, b, c]
523
+ inner = rest.lstrip("[").rstrip("]").strip()
524
+ if inner:
525
+ for item in inner.split(","):
526
+ item = item.strip().strip('"').strip("'")
527
+ if item:
528
+ sources_list.append(item)
529
+ continue
530
+ # Block list: next indented lines are items
531
+ in_sources_block = True
532
+ continue
533
+
534
+ return approved_at_commit, sources_list, sources_field_present
535
+
536
+
537
+ def is_url_source(entry: str) -> bool:
538
+ """Return True if entry matches a URL scheme (^[a-z][a-z0-9+.-]*://).
539
+
540
+ Identical to kb-freshness-check.sh is_url() and the Node twin isUrlSource().
541
+ """
542
+ return bool(_RE_URL.match(entry))
543
+
544
+
418
545
  # ---------------------------------------------------------------------------
419
546
  # Prototype: REQUIREMENTS.md parser (work-overview header, delivery-002)
420
547
  # ---------------------------------------------------------------------------
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
36
36
  from pathlib import Path
37
37
  from typing import Optional, Union
38
38
 
39
- from .derivation import derive_kb_status
39
+ from .derivation import derive_doc_freshness, derive_kb_status
40
40
  from .locator import enumerate_worktree_roots, locate_aid_root
41
41
  from .models import (
42
42
  DeferredIssue,
@@ -409,6 +409,11 @@ def _read_repo_full(
409
409
  kb_state.status = kb_status
410
410
  kb_state.kb_baseline = kb_baseline
411
411
 
412
+ # task-042: per-doc freshness (f007) -- additive; FF-A2 git_freshness_check retained
413
+ doc_freshness = derive_doc_freshness(kb_dir=loc.kb_dir, repo_root=root)
414
+ kb_state.doc_freshness = doc_freshness
415
+ kb_state.suspect_count = sum(1 for d in doc_freshness if d.verdict == "suspect")
416
+
412
417
  repo_info = RepoInfo(
413
418
  project_name=project_name,
414
419
  aid_dir=str(loc.aid_dir),