aid-installer 1.0.0 → 1.1.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.
@@ -0,0 +1,892 @@
1
+ # dashboard/reader/derivation.py
2
+ # LC-3 Fallback Adapter + SM-2 / SM-3 lifecycle derivation.
3
+ # FF-A3 KB 5-state status waterfall + FF-A2 git freshness read (task-064, feature-007).
4
+ #
5
+ # Responsibility:
6
+ # - derive_lifecycle(work_dir, pw) -> Lifecycle (SM-2: preferred or fallback path)
7
+ # - rollup_lifecycle(tasks, pending_inputs, has_impediment, deploy_done,
8
+ # cancellation_recorded) -> Lifecycle (SM-3)
9
+ # - Fallback-only helpers that scan legacy STATE.md sections when ## Pipeline Status
10
+ # is absent (LC-3 Fallback Adapter).
11
+ # - derive_kb_status(kb_dir, summary_approved, summary_present, kb_baseline, repo_root)
12
+ # -> KbStatus (FF-A3 5-state waterfall, task-064)
13
+ # - git_freshness_check(repo_root, kb_baseline) -> "approved" | "outdated" | "skip"
14
+ # (FF-A2 read-only bounded git log subprocess, task-064)
15
+ #
16
+ # MIGRATION STATUS (task-013 M6 cutover audit):
17
+ #
18
+ # NORMALIZED (producer-emitted, feature-001 M1-M6 complete):
19
+ # - Running -- M4: aid-interview, aid-specify, aid-plan, aid-detail,
20
+ # aid-execute, aid-deploy (state-idle.md) emit at phase entry.
21
+ # - Paused-Awaiting-Input + Pause Reason -- M5: aid-specify (state-blocked.md,
22
+ # state-spike.md), aid-execute (state-delivery-gate.md),
23
+ # aid-interview (state-completion.md) emit on pause transitions.
24
+ # - Blocked + Block Reason/Artifact -- M5: aid-execute (state-execute.md,
25
+ # state-review.md), aid-execute (state-delivery-gate.md)
26
+ # emit on impediment/failed-gate transitions.
27
+ # - Completed -- M6 (task-013): aid-deploy (state-done.md) emits at final
28
+ # work-completion transition (DONE state entry).
29
+ #
30
+ # LEGITIMATE FALLBACK (no automatic producer by design):
31
+ # - Canceled -- Per feature-001 SS3 SM, Canceled is a USER ACTION only
32
+ # (no automatic pipeline trigger). Its ## Lifecycle History
33
+ # scan IS the intended derivation path, not tech-debt.
34
+ # No automatic producer will ever emit Lifecycle: Canceled;
35
+ # the fallback scan is retained as the permanent mechanism.
36
+ #
37
+ # LEGACY-COMPAT (fallback code retained for works created before M4-M6):
38
+ # The fallback code paths below are no longer "temporary by construction" for
39
+ # signals that now have producers. They are retained as LEGACY-COMPAT for works
40
+ # created before the migration (no ## Pipeline Status block present).
41
+ # source_mode=fallback identifies these legacy works; ReadMeta.fallback_works
42
+ # is the runtime evidence of which works still use the fallback path.
43
+ #
44
+ # KI-003 RESOLVED (task-013): task-001 reconciled schemas.md SS13 to the flat
45
+ # IMPEDIMENT-task-NNN.md path (the path the reader's _find_impediment_file already
46
+ # scans). The reader's flat-scan path now matches the canonical documented path.
47
+ # The KI-003 coupling note is closed; the flat-scan code is correct and stays.
48
+ #
49
+ # KI-004: heartbeat is repo-level corroborating only; not used here (never a lifecycle
50
+ # primitive). Retained as a known design choice (not tech-debt).
51
+ #
52
+ # No write / no LLM / one read-only `git log` subprocess for KB freshness (FR35).
53
+ # Python 3.11+ stdlib only. Zero third-party deps.
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+ import subprocess
59
+ from datetime import datetime, timezone
60
+ from pathlib import Path
61
+ from typing import Optional
62
+
63
+ from .models import KbBaseline, KbStatus, Lifecycle, PendingInput, SourceMode, TaskModel, TaskStatus
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # FF-A2: UTC-instant normalization helper (R12, task-064)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def _normalize_to_utc_ms(iso_str: str) -> Optional[int]:
71
+ """Parse an ISO-8601 datetime string and return UTC milliseconds since epoch.
72
+
73
+ Handles both Z-suffix and +/-HH:MM offset forms. Returns None if unparseable.
74
+ Python: datetime.fromisoformat(...).astimezone(timezone.utc) (3.11 parses Z).
75
+
76
+ This is the authoritative normalization helper for cross-runtime UTC comparison
77
+ (FF-A2 step 4, R12). The Z-vs-+-HH:MM boundary unit case is in task-066.
78
+ """
79
+ if not iso_str:
80
+ return None
81
+ try:
82
+ # Python 3.11+ parses Z suffix natively
83
+ dt = datetime.fromisoformat(iso_str)
84
+ if dt.tzinfo is None:
85
+ # Assume UTC for naive datetimes (defensive; real data always has offset)
86
+ dt = dt.replace(tzinfo=timezone.utc)
87
+ dt_utc = dt.astimezone(timezone.utc)
88
+ # Return milliseconds since epoch (matching Node Date.parse / getTime)
89
+ epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
90
+ return int((dt_utc - epoch).total_seconds() * 1000)
91
+ except (ValueError, OverflowError):
92
+ return None
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # FF-A2: git freshness check (task-064, LC-A2 reader subprocess)
97
+ # ---------------------------------------------------------------------------
98
+
99
+ # Allowed git verbs: ONLY rev-parse, symbolic-ref, log (read-only)
100
+ # NEVER: fetch, pull, commit, checkout, reset, push, merge, rebase, add, rm
101
+ _GIT_ALLOWED_VERBS = frozenset({"rev-parse", "symbolic-ref", "log"})
102
+
103
+ # Degradation timeout (seconds) -- bounded read, never blocks indefinitely
104
+ _GIT_TIMEOUT_S = 2
105
+
106
+
107
+ def git_freshness_check(
108
+ repo_root: Path,
109
+ kb_baseline: Optional[KbBaseline],
110
+ ) -> str:
111
+ """FF-A2: Check if the repo's default branch has advanced past kb_baseline.
112
+
113
+ Returns one of: "approved" | "outdated" | "skip".
114
+ Every failure mode (DD-A2 7-mode degradation matrix) -> "skip" -> stay approved.
115
+
116
+ Read-only subprocess: only rev-parse / symbolic-ref / log verbs.
117
+ No fetch / pull / commit / checkout / reset / push / merge.
118
+ No file written.
119
+
120
+ argv (identical to Node reader.mjs twin):
121
+ git -C <repo_root> log -1 --format=%cI <branch>
122
+ """
123
+ # Degradation mode 6: kb_baseline absent
124
+ if kb_baseline is None:
125
+ return "skip"
126
+
127
+ # Resolve branch: prefer baseline.branch, else origin/HEAD basename, else main/master
128
+ branch = _resolve_git_branch(repo_root, kb_baseline)
129
+ if branch is None:
130
+ return "skip"
131
+
132
+ # Run: git -C <root> log -1 --format=%cI <branch>
133
+ current_tip_str = _run_git_log(repo_root, branch)
134
+ if current_tip_str is None:
135
+ return "skip"
136
+
137
+ # UTC normalization before compare (R12, never raw string compare)
138
+ current_ms = _normalize_to_utc_ms(current_tip_str)
139
+ baseline_ms = _normalize_to_utc_ms(kb_baseline.tip_date or "")
140
+ if current_ms is None or baseline_ms is None:
141
+ return "skip"
142
+
143
+ return "outdated" if current_ms > baseline_ms else "approved"
144
+
145
+
146
+ def _resolve_git_branch(repo_root: Path, kb_baseline: KbBaseline) -> Optional[str]:
147
+ """DD-A2 branch resolution: prefer baseline.branch, else origin/HEAD, else main/master."""
148
+ # Prefer baseline.branch
149
+ if kb_baseline.branch:
150
+ return kb_baseline.branch
151
+
152
+ # Try git symbolic-ref --short refs/remotes/origin/HEAD
153
+ try:
154
+ result = subprocess.run(
155
+ ["git", "-C", str(repo_root), "symbolic-ref", "--short",
156
+ "refs/remotes/origin/HEAD"],
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=_GIT_TIMEOUT_S,
160
+ )
161
+ if result.returncode == 0:
162
+ ref = result.stdout.strip()
163
+ if ref:
164
+ # basename: "origin/main" -> "main"
165
+ return ref.split("/")[-1] if "/" in ref else ref
166
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
167
+ pass
168
+
169
+ # Fallback: first of {main, master} that exists
170
+ for candidate in ("main", "master"):
171
+ try:
172
+ result = subprocess.run(
173
+ ["git", "-C", str(repo_root), "rev-parse", "--verify",
174
+ f"refs/heads/{candidate}"],
175
+ capture_output=True,
176
+ text=True,
177
+ timeout=_GIT_TIMEOUT_S,
178
+ )
179
+ if result.returncode == 0:
180
+ return candidate
181
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
182
+ # Try the next candidate (twin of reader.mjs, which continues the loop on
183
+ # a failed candidate rather than aborting) -- a spawn error on 'main' must
184
+ # not prevent checking 'master'.
185
+ continue
186
+
187
+ return None
188
+
189
+
190
+ def _run_git_log(repo_root: Path, branch: str) -> Optional[str]:
191
+ """Run: git -C <repo_root> log -1 --format=%cI <branch>
192
+
193
+ Returns the raw ISO-8601 date string on success, None on every failure.
194
+ Degradation modes: ENOENT (git absent), nonzero, empty, timeout.
195
+ """
196
+ try:
197
+ result = subprocess.run(
198
+ ["git", "-C", str(repo_root), "log", "-1", "--format=%cI", branch],
199
+ capture_output=True,
200
+ text=True,
201
+ timeout=_GIT_TIMEOUT_S,
202
+ )
203
+ if result.returncode != 0:
204
+ return None
205
+ tip = result.stdout.strip()
206
+ return tip if tip else None
207
+ except FileNotFoundError:
208
+ # git binary absent (ENOENT)
209
+ return None
210
+ except subprocess.TimeoutExpired:
211
+ return None
212
+ except OSError:
213
+ return None
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # SD-3: Worktree enumeration subprocess helpers (work-004 Pillar 4)
218
+ #
219
+ # These are the ONLY two git subprocess calls added for worktree discovery.
220
+ # Both use the same fixed-argv / no-shell / 2s-timeout / degrade pattern as
221
+ # _run_git_log above. Locator.py calls these functions; it does NOT import
222
+ # subprocess itself (per the existing subprocess-in-derivation-only architecture).
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ def run_worktree_list(repo_root: Path) -> Optional[str]:
227
+ """Run: git -C <repo_root> worktree list --porcelain
228
+
229
+ SD-3: Fixed-argv / no-shell / 2 s-bounded subprocess (twin of _run_git_log).
230
+ Returns raw stdout string on success, None on every failure mode.
231
+ Degradation modes: ENOENT (git absent), nonzero exit (non-git dir), timeout, OSError.
232
+ Never throws.
233
+
234
+ Safety guard: verifies that repo_root IS the git toplevel before running worktree
235
+ list. If repo_root is a subdirectory of a git repo (e.g. a fixture directory
236
+ nested inside a larger repo), git would walk up and report the enclosing repo's
237
+ worktrees -- which is wrong. The guard degrades to None in that case so the
238
+ caller falls back to main-root-only.
239
+
240
+ The verb "worktree" is hard-coded in the argv list; no shell is used and no
241
+ user-supplied string is executed -- the call is safe by construction (SD-3).
242
+ """
243
+ # Guard: only run worktree list if repo_root is the git toplevel.
244
+ # This prevents a fixture dir nested inside a repo from inheriting the host
245
+ # repo's worktrees.
246
+ if not _is_git_toplevel(repo_root):
247
+ return None
248
+
249
+ try:
250
+ result = subprocess.run(
251
+ ["git", "-C", str(repo_root), "worktree", "list", "--porcelain"],
252
+ capture_output=True,
253
+ text=True,
254
+ timeout=_GIT_TIMEOUT_S,
255
+ )
256
+ if result.returncode != 0:
257
+ return None
258
+ return result.stdout
259
+ except FileNotFoundError:
260
+ # git binary absent (ENOENT)
261
+ return None
262
+ except subprocess.TimeoutExpired:
263
+ return None
264
+ except OSError:
265
+ return None
266
+
267
+
268
+ def _is_git_toplevel(path: Path) -> bool:
269
+ """Return True if path is the git worktree toplevel (not a subdirectory of one).
270
+
271
+ Runs: git -C <path> rev-parse --show-toplevel
272
+ Compares the resolved result with path.resolve().
273
+ Returns False on any failure (git absent, not a git repo, timeout, mismatch).
274
+ Never throws.
275
+ """
276
+ try:
277
+ result = subprocess.run(
278
+ ["git", "-C", str(path), "rev-parse", "--show-toplevel"],
279
+ capture_output=True,
280
+ text=True,
281
+ timeout=_GIT_TIMEOUT_S,
282
+ )
283
+ if result.returncode != 0:
284
+ return False
285
+ toplevel = Path(result.stdout.strip()).resolve()
286
+ return toplevel == path.resolve()
287
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
288
+ return False
289
+
290
+
291
+ def detect_main_branch_label(repo_root: Path) -> str:
292
+ """Best-effort detection of the current branch name for the main worktree.
293
+
294
+ Used as the branch_label for the fallback main-root-only result in locator.py.
295
+ Fixed-argv / no-shell / 2 s-bounded (same pattern as _run_git_log).
296
+ Falls back to the literal string "main" on any failure.
297
+ Never throws.
298
+ """
299
+ try:
300
+ result = subprocess.run(
301
+ ["git", "-C", str(repo_root), "symbolic-ref", "--short", "HEAD"],
302
+ capture_output=True,
303
+ text=True,
304
+ timeout=_GIT_TIMEOUT_S,
305
+ )
306
+ if result.returncode == 0:
307
+ label = result.stdout.strip()
308
+ if label:
309
+ return label
310
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
311
+ pass
312
+ return "main"
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # FF-A3: KB 5-state status waterfall (task-064, feature-007 DM-A2)
317
+ # ---------------------------------------------------------------------------
318
+
319
+ def derive_kb_status(
320
+ kb_dir: Path,
321
+ summary_approved: bool,
322
+ summary_present: bool,
323
+ kb_baseline: Optional[KbBaseline],
324
+ repo_root: Path,
325
+ ) -> KbStatus:
326
+ """FF-A3: Derive the FR32 5-state KB status from disk-only signals.
327
+
328
+ Waterfall (outermost-first, DD-A3):
329
+ 1. .aid/knowledge/ absent or empty -> pending
330
+ 2. KB present but not yet User Approved: yes -> generating
331
+ (SPEC residual-#1 safe default -- applied verbatim, no design here)
332
+ 3. KB approved but kb.html absent OR summary not yet approved -> preparing
333
+ 4. freshness_check == "outdated" -> outdated
334
+ 5. else -> approved
335
+
336
+ outdated is checked LAST and ONLY over approved (DD-A3).
337
+ Never raises (NFR7).
338
+ """
339
+ try:
340
+ # Step 1: .aid/knowledge/ absent or empty -> pending
341
+ if not kb_dir.is_dir():
342
+ return KbStatus.pending
343
+ try:
344
+ entries = list(kb_dir.iterdir())
345
+ except OSError:
346
+ entries = []
347
+ if not entries:
348
+ return KbStatus.pending
349
+
350
+ # Step 2: KB present but not yet User Approved: yes -> generating
351
+ # (SPEC residual-#1: "KB present but not yet User Approved: yes" is the safe default)
352
+ if not summary_approved:
353
+ return KbStatus.generating
354
+
355
+ # Step 3: KB approved but kb.html absent OR summary not V1-approved -> preparing
356
+ if not summary_present:
357
+ return KbStatus.preparing
358
+
359
+ # Step 4+5: freshness check (last, only over approved)
360
+ freshness = git_freshness_check(repo_root, kb_baseline)
361
+ if freshness == "outdated":
362
+ return KbStatus.outdated
363
+
364
+ return KbStatus.approved
365
+
366
+ except Exception: # noqa: BLE001 -- never raises (NFR7)
367
+ return KbStatus.unknown
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # SM-2: derive_lifecycle -- unified preferred + fallback entry point
372
+ #
373
+ # PREFERRED path (normalized, feature-001 M1+):
374
+ # When ## Pipeline Status block is present and contains a valid Lifecycle
375
+ # literal, that literal is returned verbatim (source_mode=normalized).
376
+ # This path is implemented in parsers.py (parse_state_md / _parse_pipeline_status_line).
377
+ # derive_lifecycle() is called for the FALLBACK path only; the caller (parsers.py) decides
378
+ # which path to use.
379
+ #
380
+ # FALLBACK path (LC-3, legacy-compat -- see module docstring for M6 migration status):
381
+ # Apply SM-2 priority rules in order; first match wins; always returns exactly one.
382
+ # ---------------------------------------------------------------------------
383
+
384
+ def derive_lifecycle(
385
+ *,
386
+ work_dir: Path,
387
+ tasks: list[TaskModel],
388
+ pending_inputs: list[PendingInput],
389
+ state_text: str,
390
+ work_id: str = "",
391
+ ) -> tuple[Lifecycle, SourceMode, Optional[str], Optional[str], Optional[str], Optional[str], list[str]]:
392
+ """Apply SM-2 fallback derivation when ## Pipeline Status is absent.
393
+
394
+ LEGACY-COMPAT: each branch below is a legacy signal scan for works created before
395
+ the M4-M6 producer migration (no ## Pipeline Status block present). Live works now
396
+ use the normalized path (source_mode=normalized). These branches are retained for
397
+ works that pre-date the migration.
398
+
399
+ Exception: Canceled (prio-1) is the LEGITIMATE derivation path for all works,
400
+ since Canceled is a user action with no automatic producer by design (feature-001 §3).
401
+
402
+ Returns:
403
+ (lifecycle, source_mode, pause_reason, block_reason, block_artifact, updated,
404
+ extra_warnings)
405
+
406
+ Priority order (first match wins, TOTAL -- feature-002 SM-2):
407
+ 1. Canceled -- ## Lifecycle History row matching /cancel|canceled/i (best-effort).
408
+ LEGITIMATE PATH (not tech-debt): Canceled has no automatic producer.
409
+ 2. Completed -- ## Deploy Status shipped OR all ## Plan / Deliveries Done + no open task.
410
+ LEGACY-COMPAT: live works now emit Lifecycle: Completed via state-done.md.
411
+ 3. Blocked -- IMPEDIMENT-task-NNN.md exists (flat path, KI-003 RESOLVED) OR any task
412
+ Status=Failed OR ## Delivery Gates block with Grade < minimum.
413
+ LEGACY-COMPAT: live works now emit Lifecycle: Blocked via M5 producers.
414
+ 4. Paused-Awaiting-Input -- pending_inputs non-empty.
415
+ LEGACY-COMPAT: live works now emit Lifecycle: Paused-Awaiting-Input via M5.
416
+ NOTE: **User Approved:** (top blockquote) is DELIBERATELY EXCLUDED from this primitive
417
+ (SM-2 prio-4 note: it is the terminal work-completion gate, not a mid-run pause signal).
418
+ 5. Running -- default (live work with no terminal/pause/block signal).
419
+ LEGACY-COMPAT: live works now emit Lifecycle: Running via M4 producers.
420
+ """
421
+ warnings: list[str] = []
422
+
423
+ # ---- Prio 1: Canceled (LEGITIMATE PATH -- no automatic producer by design) ----
424
+ # Scan ## Lifecycle History for a row whose Phase Transition / Gate column matches
425
+ # /cancel|canceled/i. Best-effort: if not found, fall through.
426
+ # Per feature-001 §3 SM, Canceled is a user action only; no pipeline producer will
427
+ # ever emit Lifecycle: Canceled automatically. This scan is the permanent mechanism.
428
+ if _has_cancellation_in_history(state_text, warnings, work_id):
429
+ return (
430
+ Lifecycle.Canceled,
431
+ SourceMode.Fallback,
432
+ None, None, None,
433
+ _extract_latest_history_date(state_text),
434
+ warnings,
435
+ )
436
+
437
+ # ---- Prio 2: Completed (LEGACY-COMPAT) ----
438
+ # ## Deploy Status shipped OR all ## Plan / Deliveries rows Done with no open task.
439
+ # Live works (created after M6) emit Lifecycle: Completed via state-done.md at DONE entry.
440
+ # This branch is retained for legacy works created before the M6 migration.
441
+ if _is_completed(state_text, tasks):
442
+ return (
443
+ Lifecycle.Completed,
444
+ SourceMode.Fallback,
445
+ None, None, None,
446
+ _extract_latest_history_date(state_text),
447
+ warnings,
448
+ )
449
+
450
+ # ---- Prio 3: Blocked (LEGACY-COMPAT) ----
451
+ # IMPEDIMENT-task-NNN.md exists at the flat work-folder path (KI-003 RESOLVED),
452
+ # OR any task Status = Failed,
453
+ # OR ## Delivery Gates block with Grade < minimum.
454
+ # Live works (created after M5) emit Lifecycle: Blocked via state-execute.md /
455
+ # state-review.md / state-delivery-gate.md. Retained for legacy works.
456
+ block_reason, block_artifact = _find_block_signal(work_dir, tasks, state_text)
457
+ if block_reason is not None:
458
+ return (
459
+ Lifecycle.Blocked,
460
+ SourceMode.Fallback,
461
+ None, # pause_reason
462
+ block_reason,
463
+ block_artifact,
464
+ _extract_latest_history_date(state_text),
465
+ warnings,
466
+ )
467
+
468
+ # ---- Prio 4: Paused-Awaiting-Input (LEGACY-COMPAT) ----
469
+ # pending_inputs non-empty (Q{N} with Status: Pending under ## Cross-phase Q&A).
470
+ # Live works (created after M5) emit Lifecycle: Paused-Awaiting-Input via M5 producers.
471
+ # Retained for legacy works. DELIBERATELY EXCLUDES the top-blockquote **User Approved:**
472
+ # field (SM-2 prio-4 note): that field is the terminal work-completion gate; using it
473
+ # would falsely mark every not-yet-completed live work as Paused.
474
+ if pending_inputs:
475
+ q_ids = ", ".join(p.question_id for p in pending_inputs)
476
+ pause_reason = f"Pending Q&A: {q_ids}"
477
+ return (
478
+ Lifecycle.PausedAwaitingInput,
479
+ SourceMode.Fallback,
480
+ pause_reason,
481
+ None, None,
482
+ _extract_latest_history_date(state_text),
483
+ warnings,
484
+ )
485
+
486
+ # ---- Prio 5: Running -- total default (LEGACY-COMPAT) ----
487
+ # Live work with no terminal/pause/block signal.
488
+ # Live works (created after M4) emit Lifecycle: Running via M4 producers at phase entry.
489
+ # Retained for legacy works. Heartbeat is corroborating only (KI-004, design choice).
490
+ return (
491
+ Lifecycle.Running,
492
+ SourceMode.Fallback,
493
+ None, None, None,
494
+ _extract_latest_history_date(state_text),
495
+ warnings,
496
+ )
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # SM-3: work-level rollup over per-task Status (FR14)
501
+ #
502
+ # Mirrors feature-001 §3 exactly so normalized and fallback agree.
503
+ # Called by derive_lifecycle (fallback path) to decide Running vs Blocked.
504
+ # The rollup is SEPARATE from (and does not replace) the per-task tasks[] list.
505
+ #
506
+ # NOTE: This function is ALSO usable standalone for testing the rollup in isolation.
507
+ # ---------------------------------------------------------------------------
508
+
509
+ def rollup_lifecycle(
510
+ tasks: list[TaskModel],
511
+ pending_inputs: list[PendingInput],
512
+ has_impediment: bool,
513
+ deploy_done: bool,
514
+ cancellation_recorded: bool,
515
+ all_deliveries_done: bool = False,
516
+ ) -> Lifecycle:
517
+ """SM-3: derive work-level Lifecycle from the multiset of per-task Status values.
518
+
519
+ Matches feature-001 §3 deterministic rollup rule exactly.
520
+ Never collapses the per-task list; this is a complementary summary.
521
+
522
+ Priority order (first match wins):
523
+ 1. Canceled -- user cancellation recorded in history
524
+ 2. Completed -- deploy shipped OR all deliveries Done + no open task
525
+ 3. Blocked -- impediment exists OR any task.status == Failed
526
+ 4. Paused -- pending_inputs non-empty (Q&A pending)
527
+ 5. Running -- any task In Progress/In Review, OR default (between waves)
528
+
529
+ Heartbeat is never a primitive here (KI-004).
530
+ **User Approved:** is deliberately excluded from Paused (SM-2 prio-4 note).
531
+ """
532
+ if cancellation_recorded:
533
+ return Lifecycle.Canceled
534
+
535
+ if deploy_done or all_deliveries_done:
536
+ return Lifecycle.Completed
537
+
538
+ if has_impediment or any(t.status == TaskStatus.Failed for t in tasks):
539
+ return Lifecycle.Blocked
540
+
541
+ if pending_inputs:
542
+ return Lifecycle.PausedAwaitingInput
543
+
544
+ # Running: any active task, OR default for a live work between waves.
545
+ # "Between waves" is still Running -- FR16 has no Idle state.
546
+ return Lifecycle.Running
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Fallback parsing helpers (LC-3)
551
+ # Each function reads one legacy STATE.md signal.
552
+ # Status per M6 audit (task-013):
553
+ # - Canceled scan : LEGITIMATE PATH (no automatic producer; see module docstring)
554
+ # - All other scans : LEGACY-COMPAT (live works use normalized ## Pipeline Status)
555
+ # KI-003 RESOLVED: flat-scan path now matches canonical documented path (task-001).
556
+ # ---------------------------------------------------------------------------
557
+
558
+ # --- Prio 1: Cancellation (LEGITIMATE PATH -- no automatic producer by design) ---
559
+
560
+ _RE_HISTORY_SECTION = re.compile(r"^##\s+Lifecycle History\s*$", re.IGNORECASE)
561
+ _RE_TABLE_SEP = re.compile(r"^\|[\s\-|]+\|$")
562
+ _CANCEL_RE = re.compile(r"cancel(?:ed)?", re.IGNORECASE)
563
+
564
+
565
+ def _has_cancellation_in_history(text: str, warnings: list[str], work_id: str = "") -> bool:
566
+ """Scan ## Lifecycle History rows for a Phase Transition / Gate column matching /cancel|canceled/i.
567
+
568
+ Table shape (canonical/templates/work-state-template.md):
569
+ | Date | Phase Transition / Gate | Grade | Notes |
570
+
571
+ LEGITIMATE PATH (permanent): Canceled is a user action; no automatic pipeline producer
572
+ will ever emit Lifecycle: Canceled. This ## Lifecycle History scan is the intended
573
+ derivation mechanism for all works (legacy and live alike). Not retired by M6.
574
+
575
+ Returns True if a cancellation row is found; emits a warning for ambiguous rows.
576
+ """
577
+ in_history = False
578
+ header_seen = False
579
+
580
+ for line in text.splitlines():
581
+ if _RE_HISTORY_SECTION.match(line):
582
+ in_history = True
583
+ header_seen = False
584
+ continue
585
+ if in_history:
586
+ if re.match(r"^##\s+", line):
587
+ break
588
+ stripped = line.strip()
589
+ if not stripped.startswith("|"):
590
+ continue
591
+ if _RE_TABLE_SEP.match(stripped):
592
+ continue
593
+ cols = [c.strip() for c in stripped.strip("|").split("|")]
594
+ if not header_seen:
595
+ header_seen = True # skip header row
596
+ continue
597
+ # Phase Transition / Gate is column index 1 (0-based: Date|Gate|Grade|Notes)
598
+ gate_col = cols[1].strip() if len(cols) > 1 else ""
599
+ if _CANCEL_RE.search(gate_col):
600
+ return True
601
+ # Check all columns for ambiguous cancellation mentions
602
+ if any(_CANCEL_RE.search(c) for c in cols):
603
+ prefix = f"{work_id}: " if work_id else ""
604
+ warnings.append(
605
+ f"{prefix}## Lifecycle History row mentions cancellation outside "
606
+ f"Gate column (ambiguous); check manually: {stripped}"
607
+ )
608
+ return False
609
+
610
+
611
+ # --- Latest history date (used as coarse updated fallback) (TEMPORARY -- KI-003) ---
612
+
613
+ _RE_DATE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
614
+
615
+
616
+ def _extract_latest_history_date(text: str) -> Optional[str]:
617
+ """Return the most recent date found in ## Lifecycle History as the coarse updated fallback.
618
+
619
+ LEGACY-COMPAT: used only when ## Pipeline Status is absent (no Updated field).
620
+ Live works (M4+) have authoritative Updated in ## Pipeline Status; this scan is
621
+ retained for legacy works created before the migration.
622
+
623
+ Scans the Date column (first column of each data row) in ## Lifecycle History.
624
+ Returns the lexicographically latest date string ("YYYY-MM-DD"), or None if absent.
625
+ """
626
+ in_history = False
627
+ header_seen = False
628
+ latest: Optional[str] = None
629
+
630
+ for line in text.splitlines():
631
+ if _RE_HISTORY_SECTION.match(line):
632
+ in_history = True
633
+ header_seen = False
634
+ continue
635
+ if in_history:
636
+ if re.match(r"^##\s+", line):
637
+ break
638
+ stripped = line.strip()
639
+ if not stripped.startswith("|"):
640
+ continue
641
+ if _RE_TABLE_SEP.match(stripped):
642
+ continue
643
+ cols = [c.strip() for c in stripped.strip("|").split("|")]
644
+ if not header_seen:
645
+ header_seen = True
646
+ continue
647
+ # Date is the first column
648
+ date_col = cols[0].strip() if cols else ""
649
+ m = _RE_DATE.search(date_col)
650
+ if m:
651
+ d = m.group(1)
652
+ if latest is None or d > latest:
653
+ latest = d
654
+ return latest
655
+
656
+
657
+ # --- Prio 2: Completed (LEGACY-COMPAT) ---
658
+
659
+ _RE_DEPLOY_STATUS = re.compile(r"^##\s+Deploy Status\s*$", re.IGNORECASE)
660
+ _RE_PLAN_DELIVERIES = re.compile(r"^##\s+Plan\s*/\s*Deliveries\s*$", re.IGNORECASE)
661
+
662
+ # Shipped markers in the Deploy Status table
663
+ _SHIPPED_RE = re.compile(r"\b(shipped|deployed|done|complete[d]?)\b", re.IGNORECASE)
664
+
665
+ # Done markers in Plan / Deliveries Status column
666
+ _DELIVERY_DONE_RE = re.compile(r"^done$", re.IGNORECASE)
667
+ _DELIVERY_NOT_DONE_RE = re.compile(r"^(pending|in[\s-]progress|blocked)\b", re.IGNORECASE)
668
+
669
+
670
+ def _is_completed(text: str, tasks: list[TaskModel]) -> bool:
671
+ """Return True if the work appears completed from legacy signals.
672
+
673
+ LEGACY-COMPAT: two sub-checks (either fires):
674
+ (a) ## Deploy Status table has at least one row whose Status column contains
675
+ a shipped/done marker.
676
+ (b) ## Plan / Deliveries: all rows have Status=Done AND no task is open
677
+ (no In Progress / In Review task).
678
+
679
+ Live works (created after M6) emit Lifecycle: Completed via state-done.md at DONE entry.
680
+ This scan is retained for works created before the M6 migration.
681
+ """
682
+ if _deploy_status_shipped(text):
683
+ return True
684
+ if _all_deliveries_done(text) and not _has_open_task(tasks):
685
+ return True
686
+ return False
687
+
688
+
689
+ def _deploy_status_shipped(text: str) -> bool:
690
+ """Return True if ## Deploy Status contains a shipped/deployed row.
691
+
692
+ LEGACY-COMPAT. Scans the Status column (column 1) of each data row.
693
+ """
694
+ in_deploy = False
695
+ header_seen = False
696
+
697
+ for line in text.splitlines():
698
+ if _RE_DEPLOY_STATUS.match(line):
699
+ in_deploy = True
700
+ header_seen = False
701
+ continue
702
+ if in_deploy:
703
+ if re.match(r"^##\s+", line):
704
+ break
705
+ stripped = line.strip()
706
+ if not stripped.startswith("|"):
707
+ continue
708
+ if _RE_TABLE_SEP.match(stripped):
709
+ continue
710
+ cols = [c.strip() for c in stripped.strip("|").split("|")]
711
+ if not header_seen:
712
+ header_seen = True
713
+ continue
714
+ # State is column 1 (Delivery | State | PR | KB Updated | Tag | Notes)
715
+ status_col = cols[1].strip() if len(cols) > 1 else ""
716
+ if _SHIPPED_RE.search(status_col):
717
+ return True
718
+ return False
719
+
720
+
721
+ def _all_deliveries_done(text: str) -> bool:
722
+ """Return True if ## Plan / Deliveries has at least one row AND all rows are Done.
723
+
724
+ LEGACY-COMPAT. Scans Status column (column 1: Delivery | Status | Tasks | Notes).
725
+ An empty table (no rows) returns False (cannot confirm completion with no deliveries).
726
+ """
727
+ in_plan = False
728
+ header_seen = False
729
+ row_count = 0
730
+ all_done = True
731
+
732
+ for line in text.splitlines():
733
+ if _RE_PLAN_DELIVERIES.match(line):
734
+ in_plan = True
735
+ header_seen = False
736
+ row_count = 0
737
+ all_done = True
738
+ continue
739
+ if in_plan:
740
+ if re.match(r"^##\s+", line):
741
+ break
742
+ stripped = line.strip()
743
+ if not stripped.startswith("|"):
744
+ continue
745
+ if _RE_TABLE_SEP.match(stripped):
746
+ continue
747
+ cols = [c.strip() for c in stripped.strip("|").split("|")]
748
+ if not header_seen:
749
+ header_seen = True
750
+ continue
751
+ # Skip _none yet_ placeholder
752
+ if any("_none yet_" in c for c in cols):
753
+ continue
754
+ # Status column (index 1)
755
+ status_col = cols[1].strip() if len(cols) > 1 else ""
756
+ if not status_col:
757
+ continue
758
+ row_count += 1
759
+ if not _DELIVERY_DONE_RE.match(status_col):
760
+ all_done = False
761
+
762
+ return in_plan and row_count > 0 and all_done
763
+
764
+
765
+ def _has_open_task(tasks: list[TaskModel]) -> bool:
766
+ """Return True if any task has an open (in-progress/in-review) status."""
767
+ open_statuses = {TaskStatus.InProgress, TaskStatus.InReview}
768
+ return any(t.status in open_statuses for t in tasks)
769
+
770
+
771
+ # --- Prio 3: Blocked (LEGACY-COMPAT) ---
772
+
773
+ _IMPEDIMENT_RE = re.compile(r"^IMPEDIMENT-task-\w+\.md$", re.IGNORECASE)
774
+ _RE_DELIVERY_GATES = re.compile(r"^##\s+Delivery Gates\s*$", re.IGNORECASE)
775
+ _RE_GRADE_LINE = re.compile(r"\*\*Grade:\*\*\s*(\S+)", re.IGNORECASE)
776
+ _RE_MINIMUM_GRADE_LINE = re.compile(r"\*\*Minimum Grade:\*\*\s*(\S+)", re.IGNORECASE)
777
+
778
+ # Grade order for comparison (lowest to highest)
779
+ _GRADE_ORDER = ["F", "D", "C", "B", "A"]
780
+
781
+
782
+ def _find_block_signal(
783
+ work_dir: Path,
784
+ tasks: list[TaskModel],
785
+ state_text: str,
786
+ ) -> tuple[Optional[str], Optional[str]]:
787
+ """Return (block_reason, block_artifact) or (None, None) if no block signal found.
788
+
789
+ LEGACY-COMPAT: three sub-checks (first wins in the block priority):
790
+ (a) IMPEDIMENT-task-NNN.md exists at the flat path work_dir/IMPEDIMENT-task-NNN.md
791
+ (de-facto producer path, state-execute.md:322; KI-003 RESOLVED: this path now
792
+ matches the canonical documented path after task-001 reconciliation).
793
+ (b) Any task has Status = Failed.
794
+ (c) ## Delivery Gates block has a Grade that is below the per-work minimum grade
795
+ (from the top blockquote **Minimum Grade:**).
796
+
797
+ Live works (created after M5) emit Lifecycle: Blocked via state-execute.md /
798
+ state-review.md / state-delivery-gate.md. Retained for legacy works.
799
+ """
800
+ # (a) IMPEDIMENT file -- flat path per KI-003 RESOLVED (canonical documented path)
801
+ impediment_path = _find_impediment_file(work_dir)
802
+ if impediment_path is not None:
803
+ artifact = impediment_path.name
804
+ return f"IMPEDIMENT file present: {artifact}", artifact
805
+
806
+ # (b) Failed task
807
+ failed_tasks = [t for t in tasks if t.status == TaskStatus.Failed]
808
+ if failed_tasks:
809
+ ids = ", ".join(t.task_id for t in failed_tasks)
810
+ return f"Task(s) failed: {ids}", None
811
+
812
+ # (c) Sub-minimum delivery gate
813
+ gate_fail = _find_subminimum_gate(state_text)
814
+ if gate_fail:
815
+ return f"Delivery gate below minimum: {gate_fail}", gate_fail
816
+
817
+ return None, None
818
+
819
+
820
+ def _find_impediment_file(work_dir: Path) -> Optional[Path]:
821
+ """Return the first IMPEDIMENT-task-NNN.md file in work_dir, or None.
822
+
823
+ KI-003 RESOLVED (task-013): scans the FLAT work_dir/ path.
824
+ The producer writes to .aid/{work}/IMPEDIMENT-task-NNN.md (state-execute.md:322).
825
+ task-001 reconciled schemas.md §13 to this flat path; the reader's scan now matches
826
+ the canonical documented path. No update needed.
827
+ """
828
+ try:
829
+ for entry in work_dir.iterdir():
830
+ if entry.is_file() and _IMPEDIMENT_RE.match(entry.name):
831
+ return entry
832
+ except OSError:
833
+ pass
834
+ return None
835
+
836
+
837
+ def _find_subminimum_gate(state_text: str) -> Optional[str]:
838
+ """Return the delivery id of a ## Delivery Gates block with Grade < minimum, or None.
839
+
840
+ LEGACY-COMPAT: reads the top-blockquote **Minimum Grade:** + per-delivery **Grade:**.
841
+ A Gate is sub-minimum when its Grade falls below the minimum in the grade order.
842
+
843
+ Scans ## Delivery Gates for ### delivery-NNN sub-sections;
844
+ reads **Grade:** lines to find a grade below the minimum.
845
+ """
846
+ minimum_grade = _parse_minimum_grade(state_text)
847
+
848
+ in_gates = False
849
+ current_delivery: Optional[str] = None
850
+
851
+ for line in state_text.splitlines():
852
+ if _RE_DELIVERY_GATES.match(line):
853
+ in_gates = True
854
+ current_delivery = None
855
+ continue
856
+ if in_gates:
857
+ if re.match(r"^##\s+", line) and not re.match(r"^###\s+", line):
858
+ break
859
+ # Delivery sub-section header: ### delivery-NNN
860
+ m = re.match(r"^###\s+(\S+)", line)
861
+ if m:
862
+ current_delivery = m.group(1)
863
+ continue
864
+ if current_delivery:
865
+ gm = _RE_GRADE_LINE.search(line)
866
+ if gm:
867
+ grade = gm.group(1).strip().upper()
868
+ if minimum_grade and _grade_below(grade, minimum_grade):
869
+ return current_delivery
870
+
871
+ return None
872
+
873
+
874
+ def _parse_minimum_grade(text: str) -> Optional[str]:
875
+ """Extract **Minimum Grade:** from the top blockquote in STATE.md.
876
+
877
+ The top blockquote is the section before the first ## section header.
878
+ """
879
+ for line in text.splitlines():
880
+ if re.match(r"^##\s+", line):
881
+ break
882
+ m = _RE_MINIMUM_GRADE_LINE.search(line)
883
+ if m:
884
+ return m.group(1).strip().upper()
885
+ return None
886
+
887
+
888
+ def _grade_below(grade: str, minimum: str) -> bool:
889
+ """Return True if grade is strictly below minimum in the grade order."""
890
+ if grade not in _GRADE_ORDER or minimum not in _GRADE_ORDER:
891
+ return False
892
+ return _GRADE_ORDER.index(grade) < _GRADE_ORDER.index(minimum)