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.
- package/README.md +4 -2
- package/VERSION +1 -1
- package/bin/aid +2444 -193
- package/bin/aid.ps1 +2360 -105
- package/dashboard/home.html +3321 -0
- package/dashboard/index.html +987 -0
- package/dashboard/reader/__init__.py +56 -0
- package/dashboard/reader/derivation.py +892 -0
- package/dashboard/reader/locator.py +228 -0
- package/dashboard/reader/models.py +408 -0
- package/dashboard/reader/parsers.py +2105 -0
- package/dashboard/reader/reader.py +1196 -0
- package/dashboard/server/__init__.py +3 -0
- package/dashboard/server/reader.mjs +3699 -0
- package/dashboard/server/server.mjs +780 -0
- package/dashboard/server/server.py +1004 -0
- package/lib/AidInstallCore.psm1 +446 -43
- package/lib/aid-install-core.sh +405 -48
- package/package.json +5 -2
- package/scripts/postinstall.js +106 -0
- package/scripts/vendor.js +98 -0
|
@@ -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)
|