aid-installer 0.7.5 → 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,228 @@
1
+ # dashboard/reader/locator.py
2
+ # LC-1 Locator: resolve .aid/ root, enumerate work-NNN-*/ dirs, stat manifest/KB.
3
+ #
4
+ # Responsibility: filesystem listing + read-only git worktree enumeration.
5
+ # No parse (beyond worktree-list output), no write, no derivation.
6
+ # Read-only by construction: stat + iterdir + read-only git worktree list;
7
+ # the git subprocess is delegated to derivation.py (the one module permitted
8
+ # to call subprocess per the existing architecture contract; see FR35).
9
+ # This module does NOT use subprocess directly.
10
+ #
11
+ # Python 3.11+ stdlib only. Zero third-party deps.
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+ from typing import NamedTuple, Optional
18
+
19
+ # Work-folder glob pattern: exactly work-[0-9]*-* (FR12 / feature-002 LC-1)
20
+ # Matches work-NNN-{slug} directories only; excludes .temp/, .heartbeat/, etc.
21
+ _WORK_GLOB = "work-[0-9]*-*"
22
+
23
+ # Compiled pattern for the same rule (used to double-check against symlinks or files)
24
+ _WORK_RE = re.compile(r"^work-[0-9]+-")
25
+
26
+
27
+ class LocatorResult(NamedTuple):
28
+ """Output of locate_aid_root()."""
29
+ aid_dir: Path # resolved .aid/ path (always set; may not exist)
30
+ aid_exists: bool # whether .aid/ actually exists on disk
31
+ manifest_path: Path # .aid/.aid-manifest.json (may not exist)
32
+ version_path: Path # .aid/.aid-version (may not exist; fallback for ToolInfo)
33
+ settings_path: Path # .aid/settings.yml
34
+ kb_dir: Path # .aid/knowledge/ (may not exist)
35
+ work_dirs: list[Path] # .aid/work-NNN-*/ directories (sorted, dirs only)
36
+ heartbeat_dir: Path # .aid/.heartbeat/ (stat-only; may not exist)
37
+
38
+
39
+ def locate_aid_root(repo_root: str | Path) -> LocatorResult:
40
+ """Resolve the .aid/ tree from repo_root and enumerate work folders.
41
+
42
+ Structurally excludes .aid/.temp/ and .aid/.heartbeat/ -- only work-NNN-*
43
+ dirs are returned as works (the work-glob is the only filter needed; no
44
+ special-casing of .temp or .heartbeat is required because they don't match
45
+ the glob).
46
+
47
+ Never throws. If .aid/ is absent, aid_exists=False and work_dirs=[].
48
+ """
49
+ root = Path(repo_root).resolve()
50
+ aid_dir = root / ".aid"
51
+
52
+ manifest_path = aid_dir / ".aid-manifest.json"
53
+ version_path = aid_dir / ".aid-version"
54
+ settings_path = aid_dir / "settings.yml"
55
+ kb_dir = aid_dir / "knowledge"
56
+ heartbeat_dir = aid_dir / ".heartbeat"
57
+
58
+ aid_exists = aid_dir.is_dir()
59
+ work_dirs: list[Path] = []
60
+
61
+ if aid_exists:
62
+ work_dirs = _enumerate_work_dirs(aid_dir)
63
+
64
+ return LocatorResult(
65
+ aid_dir=aid_dir,
66
+ aid_exists=aid_exists,
67
+ manifest_path=manifest_path,
68
+ version_path=version_path,
69
+ settings_path=settings_path,
70
+ kb_dir=kb_dir,
71
+ work_dirs=work_dirs,
72
+ heartbeat_dir=heartbeat_dir,
73
+ )
74
+
75
+
76
+ def _enumerate_work_dirs(aid_dir: Path) -> list[Path]:
77
+ """Return sorted list of .aid/work-NNN-*/ directories.
78
+
79
+ Uses the exact glob work-[0-9]*-* (FR12). Returns only entries that:
80
+ - match the glob
81
+ - are actual directories (not files, not symlinks to non-dirs)
82
+ - match the _WORK_RE pattern (belt-and-suspenders: glob already filters,
83
+ but explicit check guards against oddly-named dirs on case-insensitive FS)
84
+
85
+ .aid/.temp/ and .aid/.heartbeat/ are structurally excluded by the glob --
86
+ they do not start with "work-[0-9]" so they are never yielded.
87
+ """
88
+ try:
89
+ candidates = list(aid_dir.glob(_WORK_GLOB))
90
+ except OSError:
91
+ return []
92
+
93
+ result = []
94
+ for p in candidates:
95
+ if p.is_dir() and _WORK_RE.match(p.name):
96
+ result.append(p)
97
+
98
+ result.sort(key=lambda p: p.name)
99
+ return result
100
+
101
+
102
+ def stat_path(path: Path) -> Optional[int]:
103
+ """Return file size in bytes if path exists and is a regular file; else None."""
104
+ try:
105
+ st = path.stat()
106
+ if path.is_file():
107
+ return st.st_size
108
+ return None
109
+ except OSError:
110
+ return None
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # SD-3: Worktree enumeration (work-004 Pillar 4)
115
+ #
116
+ # The git subprocess calls (worktree list, symbolic-ref) are delegated to
117
+ # derivation.py -- the one reader module permitted to use the subprocess module
118
+ # per the existing architecture contract (FR35, SEC-A1). This module contains
119
+ # only the pure-Python parsing and filesystem-traversal logic; it never calls
120
+ # subprocess directly.
121
+ # ---------------------------------------------------------------------------
122
+
123
+ # Regex to detect "worktree <path>" lines in --porcelain output.
124
+ _RE_WORKTREE_LINE = re.compile(r"^worktree\s+(.+)$")
125
+ # Regex to detect "branch refs/heads/<name>" lines.
126
+ _RE_BRANCH_LINE = re.compile(r"^branch\s+refs/heads/(.+)$")
127
+ # Label used for detached-HEAD worktrees (no branch line in --porcelain output).
128
+ _DETACHED_LABEL = "(detached)"
129
+
130
+
131
+ def enumerate_worktree_roots(repo_root: str | Path) -> list[tuple[str, Path]]:
132
+ """Enumerate all persistent git worktrees for repo_root and return per-worktree roots.
133
+
134
+ Delegates the read-only `git -C <root> worktree list --porcelain` call to
135
+ derivation.run_worktree_list() (the fixed-argv / no-shell pattern, twin of
136
+ _run_git_log). The verb is hard-coded in the argv list inside derivation.py;
137
+ no shell and no user-supplied string is ever executed.
138
+
139
+ For each worktree, locates its .aid/ directory (worktree_path/.aid/). The main
140
+ worktree (first record in --porcelain output) is ALWAYS included.
141
+
142
+ Returns a list of (branch_label, aid_dir) pairs, one per worktree. The main
143
+ worktree is always the first element.
144
+
145
+ Degradation (SD-3 DD-A2): git absent / non-git / timeout / parse failure ->
146
+ returns [(main_branch_label, main_aid_dir)] (main-root-only fallback).
147
+ Never throws.
148
+ """
149
+ # Lazy import to avoid circular imports (locator -> derivation; derivation
150
+ # imports models but NOT locator, so this direction is safe).
151
+ from .derivation import detect_main_branch_label, run_worktree_list # noqa: PLC0415
152
+
153
+ root = Path(repo_root).resolve()
154
+ main_aid = root / ".aid"
155
+ # Main-root-only fallback (returned on any failure mode)
156
+ main_label = detect_main_branch_label(root)
157
+ main_fallback: list[tuple[str, Path]] = [(main_label, main_aid)]
158
+
159
+ porcelain = run_worktree_list(root)
160
+ if porcelain is None:
161
+ return main_fallback
162
+
163
+ parsed = _parse_worktree_porcelain(porcelain)
164
+ if not parsed:
165
+ return main_fallback
166
+
167
+ results: list[tuple[str, Path]] = []
168
+ for wt_path, branch_label in parsed:
169
+ wt_aid = wt_path / ".aid"
170
+ results.append((branch_label, wt_aid))
171
+
172
+ if not results:
173
+ return main_fallback
174
+
175
+ return results
176
+
177
+
178
+ def _parse_worktree_porcelain(output: str) -> list[tuple[Path, str]]:
179
+ """Parse `git worktree list --porcelain` output into (worktree_path, branch_label) pairs.
180
+
181
+ The --porcelain format groups records by blank lines:
182
+ worktree /abs/path/to/worktree
183
+ HEAD <sha>
184
+ branch refs/heads/<branch> # absent for detached HEADs
185
+
186
+ Returns list of (Path, branch_label) pairs; detached HEADs get label "(detached)".
187
+ Returns [] on any parse failure (caller degrades to main-root-only).
188
+ Never throws. No subprocess calls; no I/O.
189
+ """
190
+ try:
191
+ records: list[tuple[Path, str]] = []
192
+ current_path: Optional[Path] = None
193
+ current_branch: Optional[str] = None
194
+
195
+ for raw_line in output.splitlines():
196
+ line = raw_line.rstrip()
197
+
198
+ if not line:
199
+ # Blank line: flush the current record (if any)
200
+ if current_path is not None:
201
+ label = current_branch if current_branch is not None else _DETACHED_LABEL
202
+ records.append((current_path, label))
203
+ current_path = None
204
+ current_branch = None
205
+ continue
206
+
207
+ wt_m = _RE_WORKTREE_LINE.match(line)
208
+ if wt_m:
209
+ # If we have a pending record without a trailing blank, flush it.
210
+ if current_path is not None:
211
+ label = current_branch if current_branch is not None else _DETACHED_LABEL
212
+ records.append((current_path, label))
213
+ current_branch = None
214
+ current_path = Path(wt_m.group(1).strip())
215
+ continue
216
+
217
+ br_m = _RE_BRANCH_LINE.match(line)
218
+ if br_m:
219
+ current_branch = br_m.group(1).strip()
220
+
221
+ # Flush any trailing record (output may not end with a blank line)
222
+ if current_path is not None:
223
+ label = current_branch if current_branch is not None else _DETACHED_LABEL
224
+ records.append((current_path, label))
225
+
226
+ return records
227
+ except Exception: # noqa: BLE001 -- never throws
228
+ return []
@@ -0,0 +1,408 @@
1
+ # dashboard/reader/models.py
2
+ # Normalized in-memory display model for the AID state reader (feature-002).
3
+ #
4
+ # These types are the single source of truth for the reader's output shape.
5
+ # They mirror the enum vocabularies declared in feature-001's work-state-template.md (DM-6).
6
+ #
7
+ # Python 3.11+ stdlib only. Read-only data records; no persistence, no I/O.
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # DM-6: Enum definitions
18
+ # Single source of truth = feature-001 (canonical/templates/work-state-template.md).
19
+ # Members reproduced here verbatim so the reader's switch is total.
20
+ # The reader adds one reader-only sentinel per enum (Unknown) per DM-6 contract.
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class Lifecycle(str, Enum):
24
+ """FR16 lifecycle states for a work folder.
25
+
26
+ Members from feature-001 work-state-template.md:
27
+ Running | Paused-Awaiting-Input | Blocked | Completed | Canceled
28
+ Reader-only:
29
+ Unknown -- returned when the normalized block is absent and no fallback fires.
30
+ Never written to disk. See task-011 for the full fallback adapter.
31
+ """
32
+ Running = "Running"
33
+ PausedAwaitingInput = "Paused-Awaiting-Input"
34
+ Blocked = "Blocked"
35
+ Completed = "Completed"
36
+ Canceled = "Canceled"
37
+ Unknown = "Unknown" # reader-only sentinel; never written to disk
38
+
39
+
40
+ class Phase(str, Enum):
41
+ """Pipeline phase enum.
42
+
43
+ Members from feature-001 work-state-template.md:
44
+ Interview | Specify | Plan | Detail | Execute | Deploy | Monitor
45
+ Reader-only:
46
+ Unknown -- for an unrecognized Phase literal.
47
+ """
48
+ Interview = "Interview"
49
+ Specify = "Specify"
50
+ Plan = "Plan"
51
+ Detail = "Detail"
52
+ Execute = "Execute"
53
+ Deploy = "Deploy"
54
+ Monitor = "Monitor"
55
+ Unknown = "Unknown" # reader-only sentinel; never written to disk
56
+
57
+
58
+ class TaskStatus(str, Enum):
59
+ """Per-task status enum.
60
+
61
+ Members from feature-001 work-state-template.md (closed; single source of truth):
62
+ Pending | In Progress | In Review | Blocked | Done | Failed | Canceled
63
+ Reader-only:
64
+ Unknown -- for a row whose Status string matches no enum member (NFR7: never throws).
65
+ Never written to disk.
66
+ """
67
+ Pending = "Pending"
68
+ InProgress = "In Progress"
69
+ InReview = "In Review"
70
+ Blocked = "Blocked"
71
+ Done = "Done"
72
+ Failed = "Failed"
73
+ Canceled = "Canceled"
74
+ Unknown = "Unknown" # reader-only sentinel; never written to disk
75
+
76
+
77
+ class SourceMode(str, Enum):
78
+ """Records which derivation path produced WorkModel.lifecycle."""
79
+ Normalized = "normalized" # ## Pipeline Status block was present
80
+ Fallback = "fallback" # legacy derivation (task-011)
81
+ Mixed = "mixed" # rare: partial migration (task-011)
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # DM-2: Level 0 -- ToolInfo
86
+ # ---------------------------------------------------------------------------
87
+
88
+ @dataclass
89
+ class ToolInfo:
90
+ """Level-0 tool / installation metadata parsed from .aid/.aid-manifest.json.
91
+
92
+ manifest_present=False means the manifest file was absent; fields are then None.
93
+ Never errors on absent manifest -- render 'tool info unavailable'.
94
+ """
95
+ manifest_present: bool
96
+ aid_version: Optional[str] = None
97
+ installed_at: Optional[str] = None
98
+ tools_installed: list[str] = field(default_factory=list)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # DM-3: Level 1 -- RepoInfo + KbStateRef
103
+ # ---------------------------------------------------------------------------
104
+
105
+ class KbStatus(str, Enum):
106
+ """FR32 5-state KB status enum (feature-007 DM-A2).
107
+
108
+ Derived by the reader (FF-A3 waterfall); never written to disk (NFR2).
109
+ Members:
110
+ pending -- .aid/knowledge/ absent or empty
111
+ generating -- KB present but not yet User Approved: yes (safe default, SPEC residual-#1)
112
+ preparing -- KB approved but kb.html absent OR summary not yet V1-approved
113
+ approved -- KB + kb.html ready, current, approved
114
+ outdated -- approved but default branch has advanced past kb_baseline (FR35)
115
+ Reader-only:
116
+ unknown -- un-derivable combination falls back to pending treatment; never thrown
117
+ """
118
+ pending = "pending"
119
+ generating = "generating"
120
+ preparing = "preparing"
121
+ approved = "approved"
122
+ outdated = "outdated"
123
+ unknown = "unknown" # reader-only sentinel; never written to disk
124
+
125
+
126
+ @dataclass
127
+ class KbBaseline:
128
+ """Parsed projection of .aid/settings.yml kb_baseline block (DM-A4).
129
+
130
+ branch: the default branch the KB reflects (e.g. 'master'); None if absent
131
+ tip_date: ISO-8601 commit date of that branch's tip at KB generation time; None if absent
132
+ """
133
+ branch: Optional[str] = None
134
+ tip_date: Optional[str] = None
135
+
136
+
137
+ @dataclass
138
+ class KbStateRef:
139
+ """KB state reference (feature-007 DM-A1 extended KbStateRef).
140
+
141
+ Populated from .aid/knowledge/STATE.md + .aid/knowledge/README.md + derivation.
142
+ Absent KB (.aid/knowledge/ missing) -> null in RepoInfo.
143
+
144
+ Retained fields (feature-002 DM-3):
145
+ summary_approved -- from STATE.md ## Knowledge Summary Status **User Approved:** yes/no
146
+ last_summary_date -- parenthesized date on that line
147
+ doc_count -- rows in README.md ## Completeness table
148
+
149
+ New fields (feature-007 DM-A1, task-064):
150
+ status -- FR32 5-state KbStatus (derived, never persisted; NFR2)
151
+ summary_present -- True if <repo>/.aid/dashboard/kb.html exists (stat only)
152
+ kb_baseline -- {branch, tip_date} from .aid/settings.yml; None if unset/unparseable
153
+ """
154
+ summary_approved: bool
155
+ last_summary_date: Optional[str] = None # from STATE.md "User Approved: yes (YYYY-MM-DD...)"
156
+ doc_count: Optional[int] = None # rows in README.md ## Completeness table
157
+ # feature-007 DM-A1 new fields (task-064):
158
+ status: KbStatus = KbStatus.unknown # FR32 5-state derived status
159
+ summary_present: bool = False # True if .aid/dashboard/kb.html exists
160
+ kb_baseline: Optional[KbBaseline] = None # {branch, tip_date} or None
161
+
162
+
163
+ @dataclass
164
+ class RepoInfo:
165
+ """Level-1 project / .aid/ state."""
166
+ project_name: str # from .aid/settings.yml project.name; fallback: dir basename
167
+ aid_dir: str # resolved .aid/ root path (as string)
168
+ kb_state: Optional[KbStateRef] = None
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # DM-5: Level 3 -- TaskModel
173
+ # ---------------------------------------------------------------------------
174
+
175
+ @dataclass
176
+ class TaskModel:
177
+ """One row from ## Tasks Status in STATE.md.
178
+
179
+ Table columns (work-state-template.md):
180
+ # | Task | Type | Wave | Status | Review | Elapsed | Notes
181
+ The _none yet_ placeholder row is skipped by the parser.
182
+
183
+ PF-3/PF-5 fields (feature-009, schema_version 3):
184
+ short_name -- parsed from tasks/task-NNN.md first line "# task-NNN: <title>"
185
+ delivery -- integer parsed from STATE Wave "delivery-NNN" (PF-5c; STATE wins)
186
+ lane -- integer derived from PLAN.md wave-map or prose fallback (PF-5a/5b)
187
+ """
188
+ task_id: str
189
+ type: str
190
+ wave: Optional[str] = None
191
+ status: TaskStatus = TaskStatus.Unknown
192
+ review_grade: Optional[str] = None
193
+ elapsed: Optional[str] = None
194
+ notes: Optional[str] = None
195
+ # schema_version 3 fields (PF-3 / PF-5)
196
+ short_name: Optional[str] = None
197
+ delivery: Optional[int] = None
198
+ lane: Optional[int] = None
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # DM-4: Level 2 -- WorkModel (includes PendingInput)
203
+ # ---------------------------------------------------------------------------
204
+
205
+ @dataclass
206
+ class PendingInput:
207
+ """A ### Q{N} entry under ## Cross-phase Q&A (Pending) with Status: Pending."""
208
+ question_id: str # e.g. "Q1"
209
+ category: Optional[str] = None
210
+ impact: Optional[str] = None
211
+ context: Optional[str] = None
212
+ suggested: Optional[str] = None
213
+
214
+
215
+ @dataclass
216
+ class FeatureRef:
217
+ """A single row from ## Features Status in STATE.md (prototype field)."""
218
+ number: int
219
+ name: str
220
+
221
+
222
+ @dataclass
223
+ class DeliverableRef:
224
+ """A single row from ## Plan / Deliveries in STATE.md (prototype field).
225
+
226
+ delivery_state: the SD-8 lifecycle enum from delivery-NNN/STATE.md ## Delivery Lifecycle
227
+ (Pending-Spec | Specified | Executing | Gated | Done | Blocked).
228
+ None when the delivery STATE.md is absent or the field is unparseable (legacy works).
229
+ """
230
+ number: int
231
+ name: str
232
+ task_count: int
233
+ delivery_state: Optional[str] = None
234
+
235
+
236
+ @dataclass
237
+ class WorkModel:
238
+ """Level-2 work folder state. One per .aid/work-NNN-*/ directory.
239
+
240
+ Retention = folder persistence (FR12): the model contains exactly the work
241
+ folders that exist on disk; completed and in-flight works are represented
242
+ identically.
243
+ """
244
+ work_id: str # folder name, e.g. "work-001-aid-dashboard"
245
+ name: str # display label (slug portion)
246
+ lifecycle: Lifecycle = Lifecycle.Unknown
247
+ phase: Optional[Phase] = None
248
+ active_skill: Optional[str] = None
249
+ updated: Optional[str] = None # ISO-8601 or None
250
+ created: Optional[str] = None # date string from ## Lifecycle History "Work created" row
251
+ pause_reason: Optional[str] = None # present only when lifecycle=Paused-Awaiting-Input
252
+ block_reason: Optional[str] = None # present only when lifecycle=Blocked
253
+ block_artifact: Optional[str] = None # e.g. "IMPEDIMENT-task-NNN.md"
254
+ tasks: list[TaskModel] = field(default_factory=list)
255
+ pending_inputs: list[PendingInput] = field(default_factory=list)
256
+ source_mode: SourceMode = SourceMode.Fallback
257
+ # --- prototype: work-overview header fields (delivery-002 prototype) ---
258
+ number: Optional[int] = None # from folder prefix work-NNN-... -> NNN
259
+ title: Optional[str] = None # **Name:** from REQUIREMENTS.md
260
+ description: Optional[str] = None # **Description:** from REQUIREMENTS.md
261
+ objective: Optional[str] = None # body under ## 1. Objective in REQUIREMENTS.md
262
+ work_path: Optional[str] = None # **Path:** from STATE.md ## Triage (full/lite)
263
+ recipe: Optional[str] = None # lite-path recipe from Triage, else None
264
+ features: list[FeatureRef] = field(default_factory=list) # from ## Features Status
265
+ deliverables: list[DeliverableRef] = field(default_factory=list) # from ## Plan / Deliveries
266
+ # work-004 Pillar 4: branch label from the worktree that owns this work folder.
267
+ # "main" for the main worktree; branch name for persistent worktrees; None if unknown.
268
+ branch_label: Optional[str] = None
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # DM-1 (feature-008): TaskDetail sub-model (LC-TR, task-069)
273
+ # Populated ONLY for requested task_ids (detail_task_ids param).
274
+ # Never persisted (NFR2); all fields are read-derived.
275
+ # ---------------------------------------------------------------------------
276
+
277
+ @dataclass
278
+ class Finding:
279
+ """One bullet under STATE.md ## Quick Check Findings ### task-NNN **Findings:** (DR-2).
280
+
281
+ severity: leading bracketed tag -- [CRITICAL] | [HIGH]; lower/unknown -> [MINOR] neutral
282
+ description: bullet text up to the first ' -- ' (em-dash segment separator)
283
+ location: '{source-file:line}' segment, null if absent
284
+ disposition: trailing 'Fixed-on-spot' / 'Deferred-to-gate' token, null if absent
285
+ reviewer_tier: the block's **Reviewer Tier:** line (always 'Small' for a quick check)
286
+ """
287
+ severity: str # '[CRITICAL]' | '[HIGH]' | '[MINOR]' (neutral fallback)
288
+ description: str
289
+ location: Optional[str] = None
290
+ disposition: Optional[str] = None
291
+ reviewer_tier: Optional[str] = None
292
+
293
+
294
+ @dataclass
295
+ class DeferredIssue:
296
+ """One row from delivery-NNN-issues.md filtered to Source task == task_id (DR-4).
297
+
298
+ 4-col table: Source task | Severity | Description | Status
299
+ status enum: Open | Resolved | Accepted (unknown literal -> neutral, never throws)
300
+ """
301
+ source_task: str
302
+ severity: str
303
+ description: str
304
+ status: str
305
+
306
+
307
+ @dataclass
308
+ class TaskLedger:
309
+ """Delivery-level grade context for a task (DR-3/DR-4). NOT a per-task grade.
310
+
311
+ delivery_id: resolved delivery for this task (from ## Tasks Status Wave); null if unassociated
312
+ grade: per-delivery grade from ## Delivery Gates (verbatim, never re-graded -- NFR7)
313
+ reviewer_tier: delivery reviewer tier
314
+ gate_timestamp: when the delivery gate ran
315
+ deferred_issues: rows from delivery-NNN-issues.md where Source task == task_id
316
+ """
317
+ delivery_id: Optional[str] = None
318
+ grade: Optional[str] = None
319
+ reviewer_tier: Optional[str] = None
320
+ gate_timestamp: Optional[str] = None
321
+ deferred_issues: list[DeferredIssue] = field(default_factory=list)
322
+
323
+
324
+ @dataclass
325
+ class RawStateRef:
326
+ """Verbatim STATE.md bytes the reader ALREADY read this pass (DR-1, DD-3, NFR4).
327
+
328
+ text: whole work STATE.md verbatim (reused from memory, no re-read)
329
+ byte_len: len(text) in bytes (corroborates NFR4 payload budget)
330
+ path: '.aid/{work}/STATE.md' -- read-only caption label, NOT an edit link
331
+ """
332
+ text: str
333
+ byte_len: int
334
+ path: str
335
+
336
+
337
+ @dataclass
338
+ class LogAvailability:
339
+ """Honest DM-4 log inventory for a task (DR-5, KI-008).
340
+
341
+ task_logs: always 'none' (AID persists no per-task execution log)
342
+ server_log_present: stat .aid/.temp/dashboard.log (expected-false on Windows)
343
+ heartbeat_present: stat .aid/.heartbeat/ (liveness signal, corroborating-only, KI-004)
344
+ """
345
+ task_logs: str = "none" # always "none" today (DM-4)
346
+ server_log_present: bool = False
347
+ heartbeat_present: bool = False
348
+
349
+
350
+ @dataclass
351
+ class TaskDetail:
352
+ """Forensic sub-model for one drilled task (DM-1, feature-008 LC-TR).
353
+
354
+ Populated ONLY when detail_task_ids is supplied to read_repo_detail().
355
+ The always-on read_repo() path does NOT populate this; TaskModel is unchanged.
356
+
357
+ task_id: == TaskModel.task_id (the drill key)
358
+ findings: from ## Quick Check Findings ### task-NNN **Findings:** bullets
359
+ ledger: delivery-level grade join (## Delivery Gates + delivery-NNN-issues.md)
360
+ raw_state: the already-read STATE.md bytes (no re-read, NFR4/DD-3)
361
+ logs: honest DM-4 log inventory
362
+ """
363
+ task_id: str
364
+ findings: list[Finding] = field(default_factory=list)
365
+ ledger: TaskLedger = field(default_factory=TaskLedger)
366
+ raw_state: Optional[RawStateRef] = None
367
+ logs: Optional[LogAvailability] = None
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # DM-7: ReadMeta (provenance of this read pass)
372
+ # ---------------------------------------------------------------------------
373
+
374
+ @dataclass
375
+ class ReadMeta:
376
+ """Provenance and health summary for a single read_repo() pass (DM-7).
377
+
378
+ read_at: wall-clock of this pass (the only place the reader reads the clock).
379
+ work_count: works enumerated.
380
+ fallback_works: work_ids whose source_mode != normalized (live tech-debt surface, AC4).
381
+ parse_warnings: non-fatal anomalies (missing section, unparseable row) -- never raised.
382
+ bytes_read: total bytes read across all files (corroborates NFR4 low overhead).
383
+ """
384
+ read_at: str # ISO-8601
385
+ work_count: int = 0
386
+ fallback_works: list[str] = field(default_factory=list)
387
+ parse_warnings: list[str] = field(default_factory=list)
388
+ bytes_read: int = 0
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # DM-1: Top-level RepoModel
393
+ # ---------------------------------------------------------------------------
394
+
395
+ @dataclass
396
+ class RepoModel:
397
+ """Top-level normalized in-memory display model returned by read_repo().
398
+
399
+ RepoModel
400
+ +-- tool: ToolInfo Level 0 -- machine CLI (FR7)
401
+ +-- repo: RepoInfo Level 1 -- project / .aid/ + KB-state hook
402
+ +-- works: list[WorkModel] Level 2 -- one per .aid/work-NNN-*/ folder (FR12)
403
+ +-- read: ReadMeta provenance of THIS read pass
404
+ """
405
+ tool: ToolInfo
406
+ repo: RepoInfo
407
+ works: list[WorkModel] = field(default_factory=list)
408
+ read: ReadMeta = field(default_factory=lambda: ReadMeta(read_at=""))