@grifhinz/logics-manager 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,990 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ from .config import find_repo_root
12
+
13
+
14
+ CURRENT_WORKFLOW_SCHEMA_VERSION = "1.0"
15
+
16
+ DOC_KINDS = {
17
+ "request": ("logics/request", "req", False),
18
+ "backlog": ("logics/backlog", "item", True),
19
+ "task": ("logics/tasks", "task", True),
20
+ "product": ("logics/product", "prod", False),
21
+ "architecture": ("logics/architecture", "adr", False),
22
+ }
23
+
24
+ REF_PREFIXES = ("req", "item", "task", "prod", "adr", "spec")
25
+ STATUS_IN_PROGRESS = {"draft", "ready", "in progress", "blocked"}
26
+ STATUS_DONE = {"done", "archived"}
27
+
28
+ COMPANION_PLACEHOLDERS: dict[str, tuple[str, ...]] = {
29
+ "product": (
30
+ "Summarize the product direction, the targeted user value, and the main expected outcomes.",
31
+ "Describe the user or business problem this brief resolves.",
32
+ "Primary user or segment",
33
+ "Primary product goal",
34
+ "Main open product question to resolve",
35
+ ),
36
+ "architecture": (
37
+ "Summarize the chosen direction, what changes, and the main impacted areas.",
38
+ "Describe the problem, constraints, and drivers.",
39
+ "State the chosen option and rationale.",
40
+ "Describe the rollout or migration step.",
41
+ ),
42
+ }
43
+ TOKEN_HYGIENE_PLACEHOLDERS = (
44
+ "Summarize the need, scope, and expected outcome",
45
+ "logics, workflow",
46
+ "Use when framing scope, context, and acceptance checks",
47
+ )
48
+ TOKEN_HYGIENE_SECTION_LIMITS: dict[str, dict[str, int]] = {
49
+ "request": {"Context": 24},
50
+ "backlog": {"Problem": 16, "Notes": 24},
51
+ "task": {"Context": 16, "Report": 16},
52
+ }
53
+ GOVERNANCE_PROFILES = {
54
+ "relaxed": {
55
+ "stale_days": 0,
56
+ "require_gates": False,
57
+ "require_ac_traceability": False,
58
+ "token_hygiene": False,
59
+ },
60
+ "standard": {
61
+ "stale_days": 45,
62
+ "require_gates": True,
63
+ "require_ac_traceability": True,
64
+ "token_hygiene": False,
65
+ },
66
+ "strict": {
67
+ "stale_days": 30,
68
+ "require_gates": True,
69
+ "require_ac_traceability": True,
70
+ "token_hygiene": True,
71
+ },
72
+ }
73
+
74
+ HYBRID_CACHE_JSONL_FILES = (
75
+ Path("logics/.cache/hybrid_assist_audit.jsonl"),
76
+ Path("logics/.cache/hybrid_assist_measurements.jsonl"),
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DocKind:
82
+ kind: str
83
+ directory: str
84
+ prefix: str
85
+ has_progress: bool
86
+
87
+
88
+ DOC_KIND_OBJECTS = {
89
+ name: DocKind(name, directory, prefix, has_progress)
90
+ for name, (directory, prefix, has_progress) in DOC_KINDS.items()
91
+ }
92
+
93
+
94
+ @dataclass
95
+ class DocMeta:
96
+ kind: DocKind
97
+ path: Path
98
+ ref: str
99
+ status: str | None
100
+ progress: int | None
101
+ from_version: tuple[int, int, int] | None
102
+ text: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class AuditIssue:
107
+ code: str
108
+ path: Path | None
109
+ message: str
110
+
111
+
112
+ def _indicator_value(lines: list[str], key: str) -> str | None:
113
+ pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+)\s*$")
114
+ for line in lines:
115
+ match = pattern.match(line)
116
+ if match:
117
+ return match.group(1).strip()
118
+ return None
119
+
120
+
121
+ def _status_normalized(value: str | None) -> str | None:
122
+ if value is None:
123
+ return None
124
+ return " ".join(value.split()).lower()
125
+
126
+
127
+ def _canonical_status(value: str | None) -> str | None:
128
+ if value is None:
129
+ return None
130
+ normalized = _status_normalized(value)
131
+ allowed = ("Draft", "Ready", "In progress", "Blocked", "Done", "Archived")
132
+ for candidate in allowed:
133
+ if normalized == candidate.lower():
134
+ return candidate
135
+ return value
136
+
137
+
138
+ def _progress_value(value: str | None) -> int | None:
139
+ if value is None:
140
+ return None
141
+ match = re.search(r"(\d{1,3})", value)
142
+ if match is None:
143
+ return None
144
+ try:
145
+ parsed = int(match.group(1))
146
+ except ValueError:
147
+ return None
148
+ return max(0, min(100, parsed))
149
+
150
+
151
+ def _parse_semver(value: str | None) -> tuple[int, int, int] | None:
152
+ if value is None:
153
+ return None
154
+ match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", value.strip())
155
+ if match is None:
156
+ return None
157
+ return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
158
+
159
+
160
+ def _extract_refs(text: str, prefix: str) -> set[str]:
161
+ text = re.sub(r"```mermaid\s*\n.*?\n```", "", text, flags=re.DOTALL)
162
+ pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
163
+ return {match.group(0) for match in pattern.finditer(text)}
164
+
165
+
166
+ def _has_mermaid_block(text: str) -> bool:
167
+ return "```mermaid" in text
168
+
169
+
170
+ def _decision_framing_value(text: str, label: str) -> str | None:
171
+ pattern = re.compile(rf"^\s*-\s*{re.escape(label)}\s*:\s*(.+)\s*$", re.MULTILINE)
172
+ match = pattern.search(text)
173
+ if match is None:
174
+ return None
175
+ return match.group(1).strip()
176
+
177
+
178
+ def _extract_section_lines(text: str, heading_title: str) -> list[str]:
179
+ lines = text.splitlines()
180
+ start_idx = None
181
+ target = heading_title.strip().lower()
182
+ for idx, line in enumerate(lines):
183
+ if line.startswith("# ") and line[2:].strip().lower() == target:
184
+ start_idx = idx + 1
185
+ break
186
+ if start_idx is None:
187
+ return []
188
+
189
+ section: list[str] = []
190
+ for idx in range(start_idx, len(lines)):
191
+ line = lines[idx]
192
+ if line.startswith("# "):
193
+ break
194
+ section.append(line)
195
+ return section
196
+
197
+
198
+ def _extract_section_bounds(lines: list[str], heading_title: str) -> tuple[int, int] | None:
199
+ start_idx = None
200
+ target = heading_title.strip().lower()
201
+ for idx, line in enumerate(lines):
202
+ if line.startswith("# ") and line[2:].strip().lower() == target:
203
+ start_idx = idx
204
+ break
205
+ if start_idx is None:
206
+ return None
207
+
208
+ end_idx = len(lines)
209
+ for idx in range(start_idx + 1, len(lines)):
210
+ if lines[idx].startswith("# "):
211
+ end_idx = idx
212
+ break
213
+ return start_idx, end_idx
214
+
215
+
216
+ def _extract_checkboxes(section_lines: Iterable[str]) -> list[tuple[bool, str]]:
217
+ out: list[tuple[bool, str]] = []
218
+ pattern = re.compile(r"^\s*-\s*\[([ xX])\]\s*(.+)$")
219
+ for line in section_lines:
220
+ match = pattern.match(line)
221
+ if match:
222
+ out.append((match.group(1).lower() == "x", match.group(2).strip()))
223
+ return out
224
+
225
+
226
+ def _section_content_line_count(text: str, heading: str) -> int:
227
+ return sum(1 for line in _extract_section_lines(text, heading) if line.strip())
228
+
229
+
230
+ def _extract_request_ac_ids(request: DocMeta) -> list[str]:
231
+ section = _extract_section_lines(request.text, "Acceptance criteria")
232
+ ids: set[str] = set()
233
+ pattern = re.compile(r"\b(AC\d+[a-z]?)\b", re.IGNORECASE)
234
+ for line in section:
235
+ for match in pattern.finditer(line):
236
+ ids.add(match.group(1).upper())
237
+ return sorted(ids)
238
+
239
+
240
+ def _extract_ai_context_fields(text: str) -> dict[str, str]:
241
+ section = _extract_section_lines(text, "AI Context")
242
+ fields: dict[str, str] = {}
243
+ pattern = re.compile(r"^\s*-\s*([^:]+)\s*:\s*(.+?)\s*$")
244
+ for line in section:
245
+ match = pattern.match(line.strip())
246
+ if match is None:
247
+ continue
248
+ fields[match.group(1).strip().lower()] = match.group(2).strip()
249
+ return fields
250
+
251
+
252
+ def _is_done(doc: DocMeta) -> bool:
253
+ if doc.status is not None and doc.status in STATUS_DONE:
254
+ return True
255
+ if doc.kind.has_progress and doc.progress == 100:
256
+ return True
257
+ return False
258
+
259
+
260
+ def _find_repo_root_from(start: Path) -> Path:
261
+ try:
262
+ return find_repo_root(start)
263
+ except Exception as exc:
264
+ raise SystemExit(str(exc)) from exc
265
+
266
+
267
+ def _collect_docs(repo_root: Path) -> dict[str, DocMeta]:
268
+ docs: dict[str, DocMeta] = {}
269
+ for kind in DOC_KIND_OBJECTS.values():
270
+ directory = repo_root / kind.directory
271
+ if not directory.is_dir():
272
+ continue
273
+ for path in sorted(directory.glob("*.md")):
274
+ text = path.read_text(encoding="utf-8")
275
+ lines = text.splitlines()
276
+ docs[path.stem] = DocMeta(
277
+ kind=kind,
278
+ path=path,
279
+ ref=path.stem,
280
+ status=_status_normalized(_indicator_value(lines, "Status")),
281
+ progress=_progress_value(_indicator_value(lines, "Progress")),
282
+ from_version=_parse_semver(_indicator_value(lines, "From version")),
283
+ text=text,
284
+ )
285
+ return docs
286
+
287
+
288
+ def _scope_by_paths(docs: dict[str, DocMeta], repo_root: Path, raw_paths: list[str]) -> set[str]:
289
+ included: set[str] = set()
290
+ resolved_targets = [(repo_root / raw_path).resolve() for raw_path in raw_paths]
291
+ for ref, doc in docs.items():
292
+ doc_path = doc.path.resolve()
293
+ for target in resolved_targets:
294
+ if doc_path == target or target in doc_path.parents:
295
+ included.add(ref)
296
+ break
297
+ return included
298
+
299
+
300
+ def _scope_by_refs(docs: dict[str, DocMeta], seed_refs: set[str]) -> set[str]:
301
+ included: set[str] = set()
302
+ queue = list(seed_refs)
303
+ while queue:
304
+ ref = queue.pop()
305
+ if ref in included:
306
+ continue
307
+ doc = docs.get(ref)
308
+ if doc is None:
309
+ continue
310
+ included.add(ref)
311
+
312
+ linked_refs: set[str] = set()
313
+ for prefix in REF_PREFIXES:
314
+ linked_refs.update(_extract_refs(doc.text, prefix))
315
+ for candidate in docs.values():
316
+ if ref in candidate.text:
317
+ linked_refs.add(candidate.ref)
318
+
319
+ for linked_ref in linked_refs:
320
+ if linked_ref not in included:
321
+ queue.append(linked_ref)
322
+ return included
323
+
324
+
325
+ def _apply_scope(
326
+ docs: dict[str, DocMeta],
327
+ repo_root: Path,
328
+ scope_paths: list[str],
329
+ scope_refs: list[str],
330
+ scope_since_version: tuple[int, int, int] | None,
331
+ ) -> dict[str, DocMeta]:
332
+ allowed_refs = set(docs)
333
+ if scope_paths:
334
+ allowed_refs &= _scope_by_paths(docs, repo_root, scope_paths)
335
+ if scope_refs:
336
+ allowed_refs &= _scope_by_refs(docs, set(scope_refs))
337
+ if scope_since_version is not None:
338
+ allowed_refs &= {
339
+ ref
340
+ for ref, doc in docs.items()
341
+ if doc.from_version is not None and doc.from_version >= scope_since_version
342
+ }
343
+ return {ref: doc for ref, doc in docs.items() if ref in allowed_refs}
344
+
345
+
346
+ def _linked_items_for_request(request: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
347
+ refs = _extract_refs(request.text, DOC_KIND_OBJECTS["backlog"].prefix)
348
+ return [docs[ref] for ref in sorted(refs) if ref in docs and docs[ref].kind.kind == "backlog"]
349
+
350
+
351
+ def _linked_tasks_for_item(item: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
352
+ linked: list[DocMeta] = []
353
+ for doc in docs.values():
354
+ if doc.kind.kind != "task":
355
+ continue
356
+ if item.ref in doc.text:
357
+ linked.append(doc)
358
+ return linked
359
+
360
+
361
+ def _linked_requests_for_item(item: DocMeta, docs: dict[str, DocMeta]) -> list[DocMeta]:
362
+ refs = _extract_refs(item.text, DOC_KIND_OBJECTS["request"].prefix)
363
+ return [docs[ref] for ref in sorted(refs) if ref in docs and docs[ref].kind.kind == "request"]
364
+
365
+
366
+ def _last_modified_age_days(path: Path) -> float:
367
+ return (time.time() - path.stat().st_mtime) / 86400.0
368
+
369
+
370
+ def _is_strict_scope(doc: DocMeta, cutoff: tuple[int, int, int] | None) -> bool:
371
+ if cutoff is None:
372
+ return True
373
+ if doc.from_version is None:
374
+ return False
375
+ return doc.from_version >= cutoff
376
+
377
+
378
+ def _has_ac_with_proof(text: str, ac_id: str) -> bool:
379
+ return (ac_id in text.upper()) and ("proof:" in text.lower())
380
+
381
+
382
+ def _upsert_indicator(lines: list[str], key: str, value: str) -> None:
383
+ pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+)\s*$")
384
+ heading_idx = next((idx for idx, line in enumerate(lines) if line.startswith("## ")), None)
385
+ if heading_idx is None:
386
+ return
387
+ for idx, line in enumerate(lines):
388
+ if pattern.match(line):
389
+ lines[idx] = f"> {key}: {value}"
390
+ return
391
+ insert_at = heading_idx + 1
392
+ while insert_at < len(lines) and lines[insert_at].lstrip().startswith(">"):
393
+ insert_at += 1
394
+ lines.insert(insert_at, f"> {key}: {value}")
395
+
396
+
397
+ def _insert_section(lines: list[str], heading: str, body: list[str]) -> None:
398
+ bounds = _extract_section_bounds(lines, heading)
399
+ if bounds is not None:
400
+ start_idx, end_idx = bounds
401
+ lines[start_idx:end_idx] = [f"# {heading}", *body]
402
+ return
403
+ lines.append("")
404
+ lines.extend([f"# {heading}", *body])
405
+
406
+
407
+ def _autofix_structure(path: Path, doc_kind: str) -> bool:
408
+ original = path.read_text(encoding="utf-8")
409
+ lines = original.splitlines()
410
+ modified = False
411
+
412
+ status_value = _indicator_value(lines, "Status")
413
+ canonical_status = _canonical_status(status_value)
414
+ if canonical_status and canonical_status != status_value:
415
+ _upsert_indicator(lines, "Status", canonical_status)
416
+ modified = True
417
+
418
+ schema_value = _indicator_value(lines, "Schema version")
419
+ if schema_value != CURRENT_WORKFLOW_SCHEMA_VERSION:
420
+ _upsert_indicator(lines, "Schema version", CURRENT_WORKFLOW_SCHEMA_VERSION)
421
+ modified = True
422
+
423
+ text = "\n".join(lines).rstrip() + "\n"
424
+
425
+ if doc_kind == "request":
426
+ if not _extract_checkboxes(_extract_section_lines(text, "Definition of Ready (DoR)")):
427
+ _insert_section(
428
+ lines,
429
+ "Definition of Ready (DoR)",
430
+ [
431
+ "- [ ] Problem statement is explicit and user impact is clear.",
432
+ "- [ ] Scope boundaries (in/out) are explicit.",
433
+ "- [ ] Acceptance criteria are testable.",
434
+ "- [ ] Dependencies and known risks are listed.",
435
+ ],
436
+ )
437
+ modified = True
438
+
439
+ if doc_kind == "task":
440
+ if not _extract_checkboxes(_extract_section_lines(text, "Definition of Done (DoD)")):
441
+ _insert_section(
442
+ lines,
443
+ "Definition of Done (DoD)",
444
+ [
445
+ "- [ ] Scope implemented and acceptance criteria covered.",
446
+ "- [ ] Validation commands executed and results captured.",
447
+ "- [ ] Linked request/backlog/task docs updated during completed waves and at closure.",
448
+ "- [ ] Each completed wave left a commit-ready checkpoint or an explicit exception is documented.",
449
+ "- [ ] Status is `Done` and progress is `100%`.",
450
+ ],
451
+ )
452
+ modified = True
453
+
454
+ if not modified:
455
+ return False
456
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
457
+ return True
458
+
459
+
460
+ def _autofix_ac_traceability(path: Path, ac_ids: set[str]) -> bool:
461
+ if not ac_ids:
462
+ return False
463
+
464
+ lines = path.read_text(encoding="utf-8").splitlines()
465
+ section_bounds = _extract_section_bounds(lines, "AC Traceability")
466
+ if section_bounds is None:
467
+ if lines and lines[-1].strip():
468
+ lines.append("")
469
+ lines.append("# AC Traceability")
470
+ section_bounds = _extract_section_bounds(lines, "AC Traceability")
471
+ if section_bounds is None:
472
+ return False
473
+
474
+ modified = False
475
+ for ac_id in sorted(ac_ids):
476
+ section_bounds = _extract_section_bounds(lines, "AC Traceability")
477
+ if section_bounds is None:
478
+ break
479
+ start_idx, end_idx = section_bounds
480
+ body_start = start_idx + 1
481
+ handled = False
482
+ for idx in range(body_start, end_idx):
483
+ line = lines[idx]
484
+ if ac_id not in line.upper():
485
+ continue
486
+ if "proof:" in line.lower():
487
+ handled = True
488
+ break
489
+ lines[idx] = line.rstrip() + " Proof: TODO."
490
+ modified = True
491
+ handled = True
492
+ break
493
+ if handled:
494
+ continue
495
+ insert_at = end_idx
496
+ while insert_at > body_start and not lines[insert_at - 1].strip():
497
+ insert_at -= 1
498
+ lines.insert(insert_at, f"- {ac_id} -> TODO: map this acceptance criterion to scope. Proof: TODO.")
499
+ modified = True
500
+
501
+ if not modified:
502
+ return False
503
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
504
+ return True
505
+
506
+
507
+ def _rel(repo_root: Path, path: Path | None) -> str:
508
+ if path is None:
509
+ return "(global)"
510
+ return path.relative_to(repo_root).as_posix()
511
+
512
+
513
+ def _sorted_issues(issues: Iterable[AuditIssue], repo_root: Path) -> list[AuditIssue]:
514
+ unique: dict[tuple[str, str, str], AuditIssue] = {}
515
+ for issue in issues:
516
+ key = (_rel(repo_root, issue.path), issue.code, issue.message)
517
+ unique.setdefault(key, issue)
518
+ return sorted(unique.values(), key=lambda issue: (_rel(repo_root, issue.path), issue.code, issue.message))
519
+
520
+
521
+ def _scan_hybrid_cache_for_credentials(repo_root: Path) -> list[AuditIssue]:
522
+ issues: list[AuditIssue] = []
523
+ for rel_path in HYBRID_CACHE_JSONL_FILES:
524
+ cache_path = repo_root / rel_path
525
+ if not cache_path.exists():
526
+ continue
527
+ try:
528
+ content = cache_path.read_text(encoding="utf-8")
529
+ except OSError as error:
530
+ issues.append(
531
+ AuditIssue(
532
+ code="hybrid_cache_unreadable",
533
+ path=cache_path,
534
+ message=f"could not read cache file: {error}",
535
+ )
536
+ )
537
+ continue
538
+ if "credential_value" in content:
539
+ issues.append(
540
+ AuditIssue(
541
+ code="hybrid_cache_contains_credential_value",
542
+ path=cache_path,
543
+ message="cache file contains credential_value and must not store secrets",
544
+ )
545
+ )
546
+ return issues
547
+
548
+
549
+ def audit_payload(
550
+ repo_root: Path,
551
+ *,
552
+ stale_days: int = 45,
553
+ skip_ac_traceability: bool = False,
554
+ skip_gates: bool = False,
555
+ legacy_cutoff_version: str | None = None,
556
+ group_by_doc: bool = False,
557
+ autofix_ac_traceability: bool = False,
558
+ paths: list[str] | None = None,
559
+ refs: list[str] | None = None,
560
+ since_version: str | None = None,
561
+ token_hygiene: bool = False,
562
+ autofix_structure: bool = False,
563
+ governance_profile: str = "standard",
564
+ ) -> dict[str, object]:
565
+ profile = GOVERNANCE_PROFILES[governance_profile]
566
+ if stale_days == 45:
567
+ stale_days = int(profile["stale_days"])
568
+ if not token_hygiene and profile["token_hygiene"]:
569
+ token_hygiene = True
570
+ if profile["require_gates"] is False:
571
+ skip_gates = True
572
+ if profile["require_ac_traceability"] is False:
573
+ skip_ac_traceability = True
574
+
575
+ cutoff = _parse_semver(legacy_cutoff_version)
576
+ if legacy_cutoff_version and cutoff is None:
577
+ raise SystemExit(f"Invalid --legacy-cutoff-version `{legacy_cutoff_version}`. Expected semantic version like 1.3.0.")
578
+
579
+ scope_since = _parse_semver(since_version)
580
+ if since_version and scope_since is None:
581
+ raise SystemExit(f"Invalid --since-version `{since_version}`. Expected semantic version like 1.3.0.")
582
+
583
+ all_docs = _collect_docs(repo_root)
584
+ docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
585
+
586
+ issues: list[AuditIssue] = []
587
+ autofix_targets: dict[Path, set[str]] = {}
588
+ autofix_modified: list[Path] = []
589
+
590
+ for doc in docs.values():
591
+ if doc.kind.kind != "task" or not _is_done(doc):
592
+ continue
593
+
594
+ item_refs = _extract_refs(doc.text, DOC_KIND_OBJECTS["backlog"].prefix)
595
+ if not item_refs:
596
+ issues.append(AuditIssue(code="task_missing_backlog_ref", path=doc.path, message="done task has no linked backlog item reference"))
597
+ continue
598
+
599
+ for item_ref in sorted(item_refs):
600
+ item_doc = all_docs.get(item_ref)
601
+ if item_doc is None or item_doc.kind.kind != "backlog":
602
+ issues.append(AuditIssue(code="task_refs_missing_backlog", path=doc.path, message=f"references missing backlog item `{item_ref}`"))
603
+ continue
604
+ if not _is_done(item_doc):
605
+ issues.append(AuditIssue(code="task_links_open_backlog", path=doc.path, message=f"done task linked to backlog item not closed `{item_ref}`"))
606
+ for request_doc in _linked_requests_for_item(item_doc, all_docs):
607
+ request_items = _linked_items_for_request(request_doc, all_docs)
608
+ if request_items and all(_is_done(item) for item in request_items) and not _is_done(request_doc):
609
+ issues.append(
610
+ AuditIssue(
611
+ code="request_not_closed_after_backlog_done",
612
+ path=request_doc.path,
613
+ message="all backlog items are done but request is not closed",
614
+ )
615
+ )
616
+
617
+ for doc in docs.values():
618
+ if doc.kind.kind != "backlog":
619
+ continue
620
+ if not _extract_refs(doc.text, DOC_KIND_OBJECTS["request"].prefix):
621
+ issues.append(AuditIssue(code="backlog_orphan_no_request", path=doc.path, message="orphan backlog item (no linked request)"))
622
+
623
+ for doc in docs.values():
624
+ if doc.kind.kind not in {"backlog", "task"}:
625
+ continue
626
+ product_framing = _decision_framing_value(doc.text, "Product framing")
627
+ architecture_framing = _decision_framing_value(doc.text, "Architecture framing")
628
+ product_refs = _extract_refs(doc.text, "prod")
629
+ architecture_refs = _extract_refs(doc.text, "adr")
630
+ if product_framing == "Required" and not product_refs:
631
+ issues.append(
632
+ AuditIssue(
633
+ code="product_brief_required_missing_ref",
634
+ path=doc.path,
635
+ message="product framing is required but no linked product brief was found",
636
+ )
637
+ )
638
+ if architecture_framing == "Required" and not architecture_refs:
639
+ issues.append(
640
+ AuditIssue(
641
+ code="architecture_decision_required_missing_ref",
642
+ path=doc.path,
643
+ message="architecture framing is required but no linked ADR was found",
644
+ )
645
+ )
646
+
647
+ for doc in docs.values():
648
+ if doc.kind.kind not in {"product", "architecture"}:
649
+ continue
650
+
651
+ linked_refs: set[str] = set()
652
+ for prefix in ("req", "item", "task", "prod", "adr"):
653
+ linked_refs.update(_extract_refs(doc.text, prefix))
654
+
655
+ if not any(ref.startswith(("req_", "item_", "task_")) for ref in linked_refs):
656
+ issues.append(
657
+ AuditIssue(
658
+ code="companion_doc_missing_primary_link",
659
+ path=doc.path,
660
+ message="companion doc has no linked request, backlog item, or task reference",
661
+ )
662
+ )
663
+ if not _has_mermaid_block(doc.text):
664
+ issues.append(
665
+ AuditIssue(
666
+ code="companion_doc_missing_mermaid",
667
+ path=doc.path,
668
+ message="companion doc is missing its overview Mermaid diagram",
669
+ )
670
+ )
671
+ placeholders = COMPANION_PLACEHOLDERS.get(doc.kind.kind, ())
672
+ if any(snippet in doc.text for snippet in placeholders):
673
+ issues.append(
674
+ AuditIssue(
675
+ code="companion_doc_contains_placeholders",
676
+ path=doc.path,
677
+ message="companion doc still contains generator placeholder content",
678
+ )
679
+ )
680
+ for ref in sorted(linked_refs):
681
+ if ref == doc.ref:
682
+ continue
683
+ if ref not in all_docs:
684
+ issues.append(
685
+ AuditIssue(
686
+ code="companion_doc_refs_missing_target",
687
+ path=doc.path,
688
+ message=f"companion doc references missing target `{ref}`",
689
+ )
690
+ )
691
+
692
+ for doc in docs.values():
693
+ if doc.kind.kind != "request" or _is_done(doc) is False:
694
+ continue
695
+ request_items = _linked_items_for_request(doc, all_docs)
696
+ if not request_items:
697
+ issues.append(AuditIssue(code="request_done_without_backlog", path=doc.path, message="delivered request has no linked backlog items"))
698
+ continue
699
+ for item in request_items:
700
+ if not _is_done(item):
701
+ issues.append(
702
+ AuditIssue(
703
+ code="request_done_with_open_backlog",
704
+ path=doc.path,
705
+ message=f"delivered request linked to incomplete backlog item `{item.ref}`",
706
+ )
707
+ )
708
+
709
+ if stale_days > 0:
710
+ for doc in docs.values():
711
+ if doc.status not in STATUS_IN_PROGRESS:
712
+ continue
713
+ age_days = _last_modified_age_days(doc.path)
714
+ if age_days >= stale_days:
715
+ issues.append(
716
+ AuditIssue(
717
+ code="stale_pending_doc",
718
+ path=doc.path,
719
+ message=f"stale pending doc ({age_days:.1f} days, status={doc.status})",
720
+ )
721
+ )
722
+
723
+ if not skip_ac_traceability:
724
+ for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
725
+ if not _is_strict_scope(request, cutoff):
726
+ continue
727
+ ac_ids = _extract_request_ac_ids(request)
728
+ if not ac_ids:
729
+ continue
730
+
731
+ linked_items = _linked_items_for_request(request, all_docs)
732
+ if not linked_items:
733
+ issues.append(AuditIssue(code="ac_no_linked_backlog", path=request.path, message="request has ACs but no linked backlog items"))
734
+ continue
735
+
736
+ linked_tasks: list[DocMeta] = []
737
+ for item in linked_items:
738
+ linked_tasks.extend(_linked_tasks_for_item(item, all_docs))
739
+
740
+ if not linked_tasks:
741
+ issues.append(AuditIssue(code="ac_no_linked_tasks", path=request.path, message="request has ACs but no linked tasks"))
742
+ continue
743
+
744
+ for ac_id in ac_ids:
745
+ item_has_mapping = any(_has_ac_with_proof(item.text, ac_id) for item in linked_items)
746
+ if not item_has_mapping:
747
+ if autofix_ac_traceability and linked_items:
748
+ autofix_targets.setdefault(linked_items[0].path, set()).add(ac_id)
749
+ else:
750
+ issues.append(AuditIssue(code="ac_missing_item_traceability", path=request.path, message=f"`{ac_id}` missing item-level traceability with proof"))
751
+
752
+ task_has_mapping = any(_has_ac_with_proof(task.text, ac_id) for task in linked_tasks)
753
+ if not task_has_mapping:
754
+ if autofix_ac_traceability and linked_tasks:
755
+ autofix_targets.setdefault(linked_tasks[0].path, set()).add(ac_id)
756
+ else:
757
+ issues.append(AuditIssue(code="ac_missing_task_traceability", path=request.path, message=f"`{ac_id}` missing task-level traceability with proof"))
758
+
759
+ if not skip_gates:
760
+ for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
761
+ if not _is_strict_scope(request, cutoff) or request.status not in {"ready", "in progress", "done"}:
762
+ continue
763
+ dor_checks = _extract_checkboxes(_extract_section_lines(request.text, "Definition of Ready (DoR)"))
764
+ if not dor_checks:
765
+ issues.append(AuditIssue(code="request_missing_dor", path=request.path, message="missing DoR checklist"))
766
+ elif any(not checked for checked, _label in dor_checks):
767
+ issues.append(AuditIssue(code="request_dor_unchecked", path=request.path, message="DoR checklist contains unchecked items"))
768
+
769
+ for task in [doc for doc in docs.values() if doc.kind.kind == "task"]:
770
+ if not _is_strict_scope(task, cutoff) or not _is_done(task):
771
+ continue
772
+ dod_checks = _extract_checkboxes(_extract_section_lines(task.text, "Definition of Done (DoD)"))
773
+ if not dod_checks:
774
+ issues.append(AuditIssue(code="task_missing_dod", path=task.path, message="missing DoD checklist"))
775
+ elif any(not checked for checked, _label in dod_checks):
776
+ issues.append(AuditIssue(code="task_dod_unchecked", path=task.path, message="DoD checklist contains unchecked items"))
777
+
778
+ if token_hygiene:
779
+ for doc in docs.values():
780
+ if doc.kind.kind not in {"request", "backlog", "task"}:
781
+ continue
782
+ ai_fields = _extract_ai_context_fields(doc.text)
783
+ if not ai_fields:
784
+ issues.append(
785
+ AuditIssue(
786
+ code="token_hygiene_missing_ai_context",
787
+ path=doc.path,
788
+ message="missing `# AI Context` section for compact handoff metadata",
789
+ )
790
+ )
791
+ else:
792
+ summary = ai_fields.get("summary", "")
793
+ if not summary or any(snippet.lower() in summary.lower() for snippet in TOKEN_HYGIENE_PLACEHOLDERS):
794
+ issues.append(AuditIssue(code="token_hygiene_ai_summary_weak", path=doc.path, message="AI summary is missing or still contains placeholder text"))
795
+ keywords = ai_fields.get("keywords", "")
796
+ keyword_count = len([part for part in re.split(r"[,;]", keywords) if part.strip()])
797
+ if keyword_count > 10:
798
+ issues.append(AuditIssue(code="token_hygiene_ai_keywords_too_many", path=doc.path, message=f"AI keywords should stay compact (found {keyword_count}, limit 10)"))
799
+ use_when = ai_fields.get("use when", "")
800
+ skip_when = ai_fields.get("skip when", "")
801
+ if not use_when or not skip_when:
802
+ issues.append(AuditIssue(code="token_hygiene_ai_usage_incomplete", path=doc.path, message="AI Context must define both `Use when` and `Skip when` guidance"))
803
+
804
+ section_limits = TOKEN_HYGIENE_SECTION_LIMITS.get(doc.kind.kind, {})
805
+ for heading, max_lines in section_limits.items():
806
+ line_count = _section_content_line_count(doc.text, heading)
807
+ if line_count > max_lines:
808
+ issues.append(AuditIssue(code="token_hygiene_section_too_long", path=doc.path, message=f"`# {heading}` is too verbose for lean handoffs ({line_count} lines, limit {max_lines})"))
809
+
810
+ if autofix_ac_traceability and autofix_targets:
811
+ for path, ac_ids in sorted(autofix_targets.items(), key=lambda pair: pair[0].as_posix()):
812
+ if _autofix_ac_traceability(path, ac_ids):
813
+ autofix_modified.append(path)
814
+
815
+ if autofix_modified:
816
+ all_docs = _collect_docs(repo_root)
817
+ docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
818
+ issues = [issue for issue in issues if issue.code not in {"ac_missing_item_traceability", "ac_missing_task_traceability"}]
819
+
820
+ for request in [doc for doc in docs.values() if doc.kind.kind == "request"]:
821
+ if skip_ac_traceability or not _is_strict_scope(request, cutoff):
822
+ continue
823
+ ac_ids = _extract_request_ac_ids(request)
824
+ if not ac_ids:
825
+ continue
826
+ linked_items = _linked_items_for_request(request, all_docs)
827
+ linked_tasks: list[DocMeta] = []
828
+ for item in linked_items:
829
+ linked_tasks.extend(_linked_tasks_for_item(item, all_docs))
830
+ for ac_id in ac_ids:
831
+ if linked_items and not any(_has_ac_with_proof(item.text, ac_id) for item in linked_items):
832
+ issues.append(AuditIssue(code="ac_missing_item_traceability", path=request.path, message=f"`{ac_id}` missing item-level traceability with proof"))
833
+ if linked_tasks and not any(_has_ac_with_proof(task.text, ac_id) for task in linked_tasks):
834
+ issues.append(AuditIssue(code="ac_missing_task_traceability", path=request.path, message=f"`{ac_id}` missing task-level traceability with proof"))
835
+
836
+ if autofix_structure:
837
+ for doc in docs.values():
838
+ if doc.kind.kind not in {"request", "backlog", "task"}:
839
+ continue
840
+ if _autofix_structure(doc.path, doc.kind.kind):
841
+ autofix_modified.append(doc.path)
842
+
843
+ if autofix_modified:
844
+ all_docs = _collect_docs(repo_root)
845
+ docs = _apply_scope(all_docs, repo_root, paths or [], refs or [], scope_since)
846
+ issues = []
847
+
848
+ issues.extend(_scan_hybrid_cache_for_credentials(repo_root))
849
+ sorted_issues = _sorted_issues(issues, repo_root)
850
+
851
+ by_code: dict[str, int] = {}
852
+ by_path: dict[str, int] = {}
853
+ serialized: list[dict[str, str]] = []
854
+ for issue in sorted_issues:
855
+ rel_path = _rel(repo_root, issue.path)
856
+ by_code[issue.code] = by_code.get(issue.code, 0) + 1
857
+ by_path[rel_path] = by_path.get(rel_path, 0) + 1
858
+ serialized.append({"code": issue.code, "path": rel_path, "message": issue.message})
859
+
860
+ return {
861
+ "ok": not sorted_issues,
862
+ "issue_count": len(sorted_issues),
863
+ "issues": serialized,
864
+ "counts": {
865
+ "by_code": dict(sorted(by_code.items())),
866
+ "by_path": dict(sorted(by_path.items())),
867
+ },
868
+ "autofix": {
869
+ "enabled": autofix_ac_traceability or autofix_structure,
870
+ "modified_files": [_rel(repo_root, path) for path in sorted(set(autofix_modified))],
871
+ },
872
+ "workflow_doc_count": sum(1 for directory in ("logics/request", "logics/backlog", "logics/tasks") for _ in (repo_root / directory).glob("*.md") if (repo_root / directory).is_dir()),
873
+ "group_by_doc": group_by_doc,
874
+ }
875
+
876
+
877
+ def render_audit(
878
+ repo_root: Path,
879
+ *,
880
+ stale_days: int = 45,
881
+ skip_ac_traceability: bool = False,
882
+ skip_gates: bool = False,
883
+ legacy_cutoff_version: str | None = None,
884
+ output_format: str = "text",
885
+ group_by_doc: bool = False,
886
+ autofix_ac_traceability: bool = False,
887
+ paths: list[str] | None = None,
888
+ refs: list[str] | None = None,
889
+ since_version: str | None = None,
890
+ token_hygiene: bool = False,
891
+ autofix_structure: bool = False,
892
+ governance_profile: str = "standard",
893
+ ) -> str:
894
+ payload = audit_payload(
895
+ repo_root,
896
+ stale_days=stale_days,
897
+ skip_ac_traceability=skip_ac_traceability,
898
+ skip_gates=skip_gates,
899
+ legacy_cutoff_version=legacy_cutoff_version,
900
+ group_by_doc=group_by_doc,
901
+ autofix_ac_traceability=autofix_ac_traceability,
902
+ paths=paths,
903
+ refs=refs,
904
+ since_version=since_version,
905
+ token_hygiene=token_hygiene,
906
+ autofix_structure=autofix_structure,
907
+ governance_profile=governance_profile,
908
+ )
909
+ if output_format == "json":
910
+ return json.dumps(payload, indent=2, sort_keys=True)
911
+
912
+ lines = ["Workflow audit: OK" if payload["ok"] else "Workflow audit: FAILED", f"Workflow docs inspected: {payload['workflow_doc_count']}"]
913
+ issues = payload["issues"]
914
+ if not issues:
915
+ return "\n".join(lines)
916
+ if not group_by_doc:
917
+ for issue in issues:
918
+ if issue["path"] == "(global)":
919
+ lines.append(f"- [{issue['code']}] {issue['message']}")
920
+ else:
921
+ lines.append(f"- {issue['path']}: [{issue['code']}] {issue['message']}")
922
+ return "\n".join(lines)
923
+
924
+ grouped: dict[str, list[dict[str, str]]] = {}
925
+ for issue in issues:
926
+ grouped.setdefault(issue["path"], []).append(issue)
927
+ for rel_path in sorted(grouped):
928
+ lines.append(f"- {rel_path}")
929
+ for issue in sorted(grouped[rel_path], key=lambda item: (item["code"], item["message"])):
930
+ lines.append(f" - [{issue['code']}] {issue['message']}")
931
+ return "\n".join(lines)
932
+
933
+
934
+ def build_parser() -> argparse.ArgumentParser:
935
+ parser = argparse.ArgumentParser(
936
+ prog="logics-manager audit",
937
+ description="Audit request/backlog/task workflow consistency and traceability.",
938
+ )
939
+ parser.add_argument("--stale-days", type=int, default=45, help="Threshold for stale pending docs.")
940
+ parser.add_argument("--skip-ac-traceability", action="store_true", help="Skip AC mapping/proof checks between request/backlog/task.")
941
+ parser.add_argument("--skip-gates", action="store_true", help="Skip DoR/DoD gate checks.")
942
+ parser.add_argument("--legacy-cutoff-version", help="Only enforce AC traceability and DoR/DoD gates for docs with `From version` >= this semantic version (example: 1.3.0).")
943
+ parser.add_argument("--format", choices=("text", "json"), default="text", help="Output format for audit results.")
944
+ parser.add_argument("--group-by-doc", action="store_true", help="Group text output by document path.")
945
+ parser.add_argument("--autofix-ac-traceability", action="store_true", help="Auto-add missing AC traceability skeleton entries in linked backlog/tasks docs.")
946
+ parser.add_argument("--paths", nargs="*", default=[], help="Limit the audit to docs under these relative paths.")
947
+ parser.add_argument("--refs", nargs="*", default=[], help="Limit the audit to these refs and their directly linked workflow neighborhood.")
948
+ parser.add_argument("--since-version", help="Limit the audit to docs with `From version` >= this semantic version.")
949
+ parser.add_argument("--token-hygiene", action="store_true", help="Enable compact AI context and verbosity checks for workflow docs.")
950
+ parser.add_argument("--autofix-structure", action="store_true", help="Deterministically repair missing schema metadata, AI Context, and missing gate sections.")
951
+ parser.add_argument("--governance-profile", choices=tuple(GOVERNANCE_PROFILES), default="standard", help="Apply a named governance profile when resolving default audit strictness.")
952
+ return parser
953
+
954
+
955
+ def main(argv: list[str]) -> int:
956
+ args = build_parser().parse_args(argv)
957
+ repo_root = _find_repo_root_from(Path.cwd())
958
+ payload = audit_payload(
959
+ repo_root,
960
+ stale_days=args.stale_days,
961
+ skip_ac_traceability=args.skip_ac_traceability,
962
+ skip_gates=args.skip_gates,
963
+ legacy_cutoff_version=args.legacy_cutoff_version,
964
+ group_by_doc=args.group_by_doc,
965
+ autofix_ac_traceability=args.autofix_ac_traceability,
966
+ paths=args.paths,
967
+ refs=args.refs,
968
+ since_version=args.since_version,
969
+ token_hygiene=args.token_hygiene,
970
+ autofix_structure=args.autofix_structure,
971
+ governance_profile=args.governance_profile,
972
+ )
973
+ output = json.dumps(payload, indent=2, sort_keys=True) if args.format == "json" else render_audit(
974
+ repo_root,
975
+ stale_days=args.stale_days,
976
+ skip_ac_traceability=args.skip_ac_traceability,
977
+ skip_gates=args.skip_gates,
978
+ legacy_cutoff_version=args.legacy_cutoff_version,
979
+ output_format=args.format,
980
+ group_by_doc=args.group_by_doc,
981
+ autofix_ac_traceability=args.autofix_ac_traceability,
982
+ paths=args.paths,
983
+ refs=args.refs,
984
+ since_version=args.since_version,
985
+ token_hygiene=args.token_hygiene,
986
+ autofix_structure=args.autofix_structure,
987
+ governance_profile=args.governance_profile,
988
+ )
989
+ print(output)
990
+ return 0 if payload["ok"] else 1