its-magic 0.1.2-55 → 0.1.2

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/scripts/check_intake_template_parity.py +96 -0
  3. package/template/.cursor/commands/architecture.md +4 -0
  4. package/template/.cursor/commands/auto.md +110 -15
  5. package/template/.cursor/commands/discovery.md +4 -0
  6. package/template/.cursor/commands/execute.md +343 -291
  7. package/template/.cursor/commands/intake.md +4 -0
  8. package/template/.cursor/commands/map-codebase.md +4 -0
  9. package/template/.cursor/commands/memory-audit.md +4 -0
  10. package/template/.cursor/commands/milestone-complete.md +4 -0
  11. package/template/.cursor/commands/milestone-start.md +4 -0
  12. package/template/.cursor/commands/pause.md +4 -0
  13. package/template/.cursor/commands/phase-context.md +4 -0
  14. package/template/.cursor/commands/plan-verify.md +4 -0
  15. package/template/.cursor/commands/qa.md +226 -222
  16. package/template/.cursor/commands/quick.md +13 -0
  17. package/template/.cursor/commands/refresh-context.md +4 -0
  18. package/template/.cursor/commands/release.md +505 -487
  19. package/template/.cursor/commands/research.md +4 -0
  20. package/template/.cursor/commands/resume.md +4 -0
  21. package/template/.cursor/commands/security-review.md +4 -0
  22. package/template/.cursor/commands/sprint-plan.md +4 -0
  23. package/template/.cursor/commands/status-reconcile.md +4 -0
  24. package/template/.cursor/commands/verify-work.md +4 -0
  25. package/template/.cursor/dev-environment.json.example +22 -0
  26. package/template/.cursor/scratchpad.local.example.md +40 -3
  27. package/template/.cursor/scratchpad.md +324 -262
  28. package/template/.cursorignore +1 -0
  29. package/template/docs/engineering/auto-orchestration-reference.md +121 -14
  30. package/template/docs/engineering/context/installer-owned-paths.manifest +4 -1
  31. package/template/docs/engineering/runbook.md +207 -4
  32. package/template/scripts/check_intake_template_parity.py +96 -0
  33. package/template/scripts/dev_environment_lib.py +463 -0
  34. package/template/scripts/pack_json_validate.py +130 -0
  35. package/template/scripts/project_readme_coverage_lib.py +417 -0
  36. package/template/scripts/readme_feature_coverage_lib.py +2 -2
  37. package/template/scripts/validate_project_readme_coverage.py +151 -0
@@ -0,0 +1,417 @@
1
+ """
2
+ Project README coverage: sentinels, migration, bootstrap, and backlog predicate (US-0097 / DEC-0083).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ import readme_feature_coverage_lib as rfc
14
+
15
+ CATALOG_MARKER = "<!-- project-readme-feature-catalog -->"
16
+ FRAMEWORK_CATALOG_MARKER = "<!-- readme-feature-coverage-catalog -->"
17
+ S1_H1_PATTERN = re.compile(
18
+ r"^#\s*its-magic\s*[—\-]\s*AI dev team\s*$", re.MULTILINE | re.IGNORECASE
19
+ )
20
+ S3_HEADING = "Feature coverage catalog (US-0091)"
21
+ US_ID = re.compile(r"\bUS-\d{4}\b")
22
+ BUG_ID = re.compile(r"\bBUG-\d{4}\b")
23
+
24
+ REASON_BLOCKED = "PROJECT_README_COVERAGE_BLOCKED"
25
+ REASON_GAP = "PROJECT_README_COVERAGE_GAP"
26
+ REASON_INPUT_INVALID = "PROJECT_README_INPUT_INVALID"
27
+ REASON_MIGRATION_AMBIGUOUS = "PROJECT_README_MIGRATION_AMBIGUOUS"
28
+ REASON_SENTINEL_CONFLICT = "PROJECT_README_SENTINEL_CONFLICT"
29
+ REASON_DELTA_SKIPPED = "PROJECT_README_DELTA_SKIPPED"
30
+ REASON_BOOTSTRAP_SKIPPED = "PROJECT_README_BOOTSTRAP_SKIPPED"
31
+ REASON_PLACEHOLDER_UNRESOLVED = "PROJECT_README_PLACEHOLDER_UNRESOLVED"
32
+ REASON_ENFORCE_SKIPPED = "PROJECT_README_ENFORCE_SKIPPED"
33
+
34
+ REPORT_SCHEMA_VERSION = 1
35
+ FRAMEWORK_PATHS_EXCLUDED = [
36
+ "its_magic/README.md",
37
+ "template/its_magic/README.md",
38
+ "docs/developer/README.md",
39
+ ]
40
+
41
+ SENTINEL_IDS = ("S1", "S2", "S3", "S4", "S5")
42
+
43
+
44
+ @dataclass
45
+ class SentinelResult:
46
+ matched: List[str] = field(default_factory=list)
47
+ verdict: str = "missing" # placeholder | operator_authored | project_scaffold | missing | kit_skip | ambiguous
48
+ reason_code: str = ""
49
+
50
+
51
+ def read_utf8(path: str) -> str:
52
+ with open(path, "r", encoding="utf-8") as f:
53
+ return f.read()
54
+
55
+
56
+ def write_utf8(path: str, content: str) -> None:
57
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
58
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
59
+ f.write(content)
60
+
61
+
62
+ def is_framework_kit_repo(merged: Optional[Dict[str, str]]) -> bool:
63
+ if not merged:
64
+ return False
65
+ return (merged.get("FRAMEWORK_KIT_REPO") or "0").strip() == "1"
66
+
67
+
68
+ def detect_sentinels(content: str, repo_root: str) -> List[str]:
69
+ matched: List[str] = []
70
+ if S1_H1_PATTERN.search(content):
71
+ matched.append("S1")
72
+ if FRAMEWORK_CATALOG_MARKER in content:
73
+ matched.append("S2")
74
+ if S3_HEADING in content:
75
+ matched.append("S3")
76
+ tpl_path = os.path.join(repo_root, "template", "README.md")
77
+ if os.path.isfile(tpl_path) and content == read_utf8(tpl_path):
78
+ matched.append("S4")
79
+ return matched
80
+
81
+
82
+ def _has_project_scaffold(content: str) -> bool:
83
+ return CATALOG_MARKER in content and "## For users" in content and "## Features" in content
84
+
85
+
86
+ def _has_custom_operator_prose(content: str, sentinels: List[str]) -> bool:
87
+ """True when non-framework prose coexists with placeholder sentinels (hybrid)."""
88
+ if not sentinels:
89
+ return False
90
+ lines = content.splitlines()
91
+ custom_lines = 0
92
+ for line in lines:
93
+ stripped = line.strip()
94
+ if not stripped or stripped.startswith("<!--"):
95
+ continue
96
+ if S1_H1_PATTERN.match(stripped):
97
+ continue
98
+ if FRAMEWORK_CATALOG_MARKER in stripped:
99
+ continue
100
+ if S3_HEADING in stripped:
101
+ continue
102
+ if stripped.startswith("#") and "its-magic" in stripped.lower():
103
+ continue
104
+ if stripped.startswith("*Framework workflow"):
105
+ continue
106
+ if re.match(r"^[-*]\s", stripped) and US_ID.search(stripped):
107
+ continue
108
+ if len(stripped) > 20 and not stripped.startswith("|"):
109
+ custom_lines += 1
110
+ return custom_lines >= 3
111
+
112
+
113
+ def classify_root_readme(
114
+ content: str,
115
+ repo_root: str,
116
+ framework_kit_repo: bool = False,
117
+ ) -> SentinelResult:
118
+ if framework_kit_repo:
119
+ return SentinelResult(verdict="kit_skip")
120
+ if not content.strip():
121
+ return SentinelResult(verdict="missing")
122
+ sentinels = detect_sentinels(content, repo_root)
123
+ if _has_project_scaffold(content) and not sentinels:
124
+ return SentinelResult(verdict="project_scaffold")
125
+ if sentinels:
126
+ if _has_custom_operator_prose(content, sentinels):
127
+ if len(sentinels) >= 2:
128
+ return SentinelResult(
129
+ matched=sentinels,
130
+ verdict="ambiguous",
131
+ reason_code=REASON_SENTINEL_CONFLICT,
132
+ )
133
+ return SentinelResult(
134
+ matched=sentinels,
135
+ verdict="ambiguous",
136
+ reason_code=REASON_MIGRATION_AMBIGUOUS,
137
+ )
138
+ return SentinelResult(matched=sentinels, verdict="placeholder")
139
+ return SentinelResult(matched=["S5"], verdict="operator_authored")
140
+
141
+
142
+ def extract_vision_h1_purpose(repo_root: str) -> Tuple[str, str]:
143
+ vision_path = os.path.join(repo_root, "docs", "product", "vision.md")
144
+ if not os.path.isfile(vision_path):
145
+ return "Project", "Describe your product purpose here."
146
+ text = read_utf8(vision_path)
147
+ h1 = "Project"
148
+ for line in text.splitlines():
149
+ if line.startswith("# "):
150
+ h1 = line[2:].strip()
151
+ break
152
+ purpose_parts: List[str] = []
153
+ in_section = False
154
+ for line in text.splitlines():
155
+ if line.strip() in ("## Problem", "## Value"):
156
+ in_section = True
157
+ continue
158
+ if in_section and line.startswith("## "):
159
+ in_section = False
160
+ if in_section and line.strip() and not line.startswith("#"):
161
+ purpose_parts.append(line.strip())
162
+ if len(purpose_parts) >= 3:
163
+ break
164
+ purpose = " ".join(purpose_parts[:3]) if purpose_parts else "Describe your product purpose here."
165
+ if len(purpose) > 400:
166
+ purpose = purpose[:397] + "..."
167
+ return h1, purpose
168
+
169
+
170
+ def materialize_project_scaffold(repo_root: str) -> str:
171
+ h1, purpose = extract_vision_h1_purpose(repo_root)
172
+ return (
173
+ f"# {h1}\n\n"
174
+ f"{purpose}\n\n"
175
+ "## For users\n\n"
176
+ "## For developers\n\n"
177
+ "## Features\n\n"
178
+ f"{CATALOG_MARKER}\n\n"
179
+ "*Framework workflow commands: see [its_magic/README.md](its_magic/README.md).*\n"
180
+ )
181
+
182
+
183
+ def extract_catalog_section(readme_text: str) -> str:
184
+ if CATALOG_MARKER not in readme_text:
185
+ return ""
186
+ after = readme_text.split(CATALOG_MARKER, 1)[1]
187
+ lines: List[str] = []
188
+ for line in after.splitlines():
189
+ if line.startswith("## ") and not line.startswith("### "):
190
+ break
191
+ lines.append(line)
192
+ return "\n".join(lines)
193
+
194
+
195
+ def has_catalog_coverage(catalog_text: str, item_id: str) -> bool:
196
+ if item_id not in catalog_text:
197
+ return False
198
+ for line in catalog_text.splitlines():
199
+ if item_id in line and re.match(r"^[-*]\s", line.strip()):
200
+ return True
201
+ return bool(re.search(rf"\b{re.escape(item_id)}\b", catalog_text))
202
+
203
+
204
+ def classify_project_item(item: rfc.WorkItem, enforce: bool) -> rfc.PredicateResult:
205
+ if item.status != "DONE":
206
+ return rfc.PredicateResult(False, "status:not_done")
207
+ if item.user_visible is False:
208
+ return rfc.PredicateResult(False, "explicit:false")
209
+ if item.user_visible is True:
210
+ return rfc.PredicateResult(True, "explicit:true")
211
+ if enforce:
212
+ return rfc.PredicateResult(
213
+ False,
214
+ "unset:enforce",
215
+ input_invalid=True,
216
+ invalid_reason=f"{item.item_id}: user_visible unset with PROJECT_README_ENFORCE=1",
217
+ )
218
+ return rfc.PredicateResult(False, "heuristic:out")
219
+
220
+
221
+ def run_migration(
222
+ repo_root: str,
223
+ merged: Optional[Dict[str, str]] = None,
224
+ dry_run: bool = False,
225
+ ) -> Tuple[str, List[str]]:
226
+ """Execute M1–M5; return (status, messages). status: ok | skip | error."""
227
+ messages: List[str] = []
228
+ if is_framework_kit_repo(merged):
229
+ messages.append("M1: FRAMEWORK_KIT_REPO=1 — skip consumer migration")
230
+ return "skip", messages
231
+
232
+ root_path = os.path.join(repo_root, "README.md")
233
+ its_magic_path = os.path.join(repo_root, "its_magic", "README.md")
234
+ root_content = read_utf8(root_path) if os.path.isfile(root_path) else ""
235
+ classification = classify_root_readme(root_content, repo_root)
236
+
237
+ if classification.verdict == "ambiguous":
238
+ messages.append(f"M5: {classification.reason_code}")
239
+ return "error", messages
240
+
241
+ if classification.verdict == "operator_authored":
242
+ messages.append("M2: operator-authored root (S5) — preserve root")
243
+ if not os.path.isfile(its_magic_path) and root_content.strip():
244
+ if not dry_run:
245
+ os.makedirs(os.path.dirname(its_magic_path), exist_ok=True)
246
+ write_utf8(its_magic_path, root_content)
247
+ messages.append("M2: copied root → its_magic/README.md")
248
+ return "ok", messages
249
+
250
+ if classification.verdict in ("missing", "placeholder", "project_scaffold"):
251
+ if classification.verdict == "placeholder" and root_content.strip():
252
+ if not os.path.isfile(its_magic_path) or not read_utf8(its_magic_path).strip():
253
+ if not dry_run:
254
+ os.makedirs(os.path.dirname(its_magic_path), exist_ok=True)
255
+ write_utf8(its_magic_path, root_content)
256
+ messages.append("M3: lifted root → its_magic/README.md")
257
+ if classification.verdict == "placeholder":
258
+ scaffold = materialize_project_scaffold(repo_root)
259
+ if root_content != scaffold:
260
+ if not dry_run:
261
+ write_utf8(root_path, scaffold)
262
+ messages.append("M4: replaced root with project scaffold")
263
+ elif classification.verdict == "missing":
264
+ if not dry_run:
265
+ write_utf8(root_path, materialize_project_scaffold(repo_root))
266
+ messages.append("M4: materialized project scaffold (root missing)")
267
+ return "ok", messages
268
+
269
+ if classification.verdict == "kit_skip":
270
+ return "skip", messages
271
+ messages.append(f"M5: {REASON_PLACEHOLDER_UNRESOLVED}")
272
+ return "error", messages
273
+
274
+
275
+ def build_report(
276
+ repo_root: str,
277
+ backlog_path: str,
278
+ enforce: bool,
279
+ merged: Optional[Dict[str, str]] = None,
280
+ no_kit_skip: bool = False,
281
+ ) -> Tuple[Dict[str, Any], List[str]]:
282
+ stderr: List[str] = []
283
+ kit_skip = is_framework_kit_repo(merged) and not no_kit_skip
284
+
285
+ if kit_skip:
286
+ report = {
287
+ "catalog_marker_present": False,
288
+ "coverage_missing": [],
289
+ "coverage_present": [],
290
+ "coverage_total": 0,
291
+ "framework_paths_excluded": list(FRAMEWORK_PATHS_EXCLUDED),
292
+ "gaps": [],
293
+ "kit_repo_skipped": True,
294
+ "report_schema_version": REPORT_SCHEMA_VERSION,
295
+ "repo_root": ".",
296
+ "status": "PASS",
297
+ }
298
+ return report, stderr
299
+
300
+ backlog_text = read_utf8(backlog_path)
301
+ items = rfc.parse_backlog(backlog_text)
302
+ root_path = os.path.join(repo_root, "README.md")
303
+ if not os.path.isfile(root_path):
304
+ stderr.append(f"{REASON_INPUT_INVALID}: root README.md missing")
305
+ report = _empty_fail_report()
306
+ return report, stderr
307
+
308
+ root_readme = read_utf8(root_path)
309
+ classification = classify_root_readme(root_readme, repo_root)
310
+ if classification.verdict == "ambiguous":
311
+ stderr.append(classification.reason_code or REASON_MIGRATION_AMBIGUOUS)
312
+
313
+ catalog_text = extract_catalog_section(root_readme)
314
+ marker_present = CATALOG_MARKER in root_readme
315
+
316
+ gaps: List[Dict[str, Any]] = []
317
+ present: List[str] = []
318
+ missing: List[str] = []
319
+ total = 0
320
+
321
+ for item in sorted(items, key=lambda x: x.item_id):
322
+ pred = classify_project_item(item, enforce)
323
+ if pred.input_invalid:
324
+ stderr.append(f"{REASON_INPUT_INVALID}: {pred.invalid_reason}")
325
+ continue
326
+ if not pred.in_scope:
327
+ continue
328
+ total += 1
329
+ if has_catalog_coverage(catalog_text, item.item_id):
330
+ present.append(item.item_id)
331
+ else:
332
+ missing.append(item.item_id)
333
+ gaps.append(
334
+ {
335
+ "id": item.item_id,
336
+ "kind": item.kind,
337
+ "predicate_source": pred.predicate_source,
338
+ "user_visible": item.user_visible if item.user_visible is not None else True,
339
+ }
340
+ )
341
+ stderr.append(f"{REASON_GAP}:{item.item_id}")
342
+
343
+ status = "PASS" if not missing and not any(
344
+ c in (REASON_MIGRATION_AMBIGUOUS, REASON_SENTINEL_CONFLICT) for c in stderr
345
+ ) else "FAIL"
346
+ if missing:
347
+ status = "FAIL"
348
+
349
+ report = {
350
+ "catalog_marker_present": marker_present,
351
+ "coverage_missing": sorted(missing),
352
+ "coverage_present": sorted(present),
353
+ "coverage_total": total,
354
+ "framework_paths_excluded": list(FRAMEWORK_PATHS_EXCLUDED),
355
+ "gaps": sorted(gaps, key=lambda g: g["id"]),
356
+ "kit_repo_skipped": False,
357
+ "report_schema_version": REPORT_SCHEMA_VERSION,
358
+ "repo_root": ".",
359
+ "status": status,
360
+ }
361
+ return report, stderr
362
+
363
+
364
+ def _empty_fail_report() -> Dict[str, Any]:
365
+ return {
366
+ "catalog_marker_present": False,
367
+ "coverage_missing": [],
368
+ "coverage_present": [],
369
+ "coverage_total": 0,
370
+ "framework_paths_excluded": list(FRAMEWORK_PATHS_EXCLUDED),
371
+ "gaps": [],
372
+ "kit_repo_skipped": False,
373
+ "report_schema_version": REPORT_SCHEMA_VERSION,
374
+ "repo_root": ".",
375
+ "status": "FAIL",
376
+ }
377
+
378
+
379
+ def canonical_json(obj: Dict[str, Any]) -> str:
380
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
381
+
382
+
383
+ def self_test_sentinel_matrix() -> None:
384
+ repo = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
385
+ s1 = "# its-magic — AI dev team\n"
386
+ assert "S1" in detect_sentinels(s1, repo)
387
+ s2 = f"body\n{FRAMEWORK_CATALOG_MARKER}\n"
388
+ assert "S2" in detect_sentinels(s2, repo)
389
+ s3 = f"## {S3_HEADING}\n"
390
+ assert "S3" in detect_sentinels(s3, repo)
391
+ r = classify_root_readme(s1, repo)
392
+ assert r.verdict == "placeholder"
393
+ scaffold = materialize_project_scaffold(repo)
394
+ assert CATALOG_MARKER in scaffold
395
+ assert "## For users" in scaffold
396
+
397
+
398
+ def self_test_report_schema() -> None:
399
+ required = {
400
+ "report_schema_version",
401
+ "status",
402
+ "repo_root",
403
+ "catalog_marker_present",
404
+ "coverage_present",
405
+ "coverage_missing",
406
+ "coverage_total",
407
+ "gaps",
408
+ "framework_paths_excluded",
409
+ "kit_repo_skipped",
410
+ }
411
+ report, _ = build_report(
412
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..")),
413
+ os.path.join(os.path.dirname(__file__), "..", "docs", "product", "backlog.md"),
414
+ enforce=False,
415
+ merged={"FRAMEWORK_KIT_REPO": "1"},
416
+ )
417
+ assert required <= set(report.keys())
@@ -396,7 +396,7 @@ def has_dev_coverage(section_text: str, item: WorkItem) -> bool:
396
396
  def check_template_parity(repo_root: str) -> List[str]:
397
397
  errors: List[str] = []
398
398
  pairs = (
399
- ("README.md", "template/README.md"),
399
+ ("its_magic/README.md", "template/its_magic/README.md"),
400
400
  (
401
401
  "scripts/readme_feature_coverage_lib.py",
402
402
  "template/scripts/readme_feature_coverage_lib.py",
@@ -444,7 +444,7 @@ def build_report(
444
444
  backlog_text = read_utf8(backlog_path)
445
445
  items = parse_backlog(backlog_text)
446
446
  manifest = load_affinity_manifest(repo_root)
447
- root_readme = read_utf8(os.path.join(repo_root, "README.md"))
447
+ root_readme = read_utf8(os.path.join(repo_root, "its_magic", "README.md"))
448
448
  dev_readme = read_utf8(os.path.join(repo_root, "docs", "developer", "README.md"))
449
449
 
450
450
  if not skip_parity:
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validate project root README catalog coverage vs backlog (US-0097 / DEC-0083).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+
12
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ _REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
14
+
15
+ if _REPO_ROOT not in sys.path:
16
+ sys.path.insert(0, _REPO_ROOT)
17
+ if _SCRIPT_DIR not in sys.path:
18
+ sys.path.insert(0, _SCRIPT_DIR)
19
+
20
+ import installer # noqa: E402
21
+ import project_readme_coverage_lib as prc # noqa: E402
22
+
23
+
24
+ def _enforce_flag(merged: dict) -> bool:
25
+ raw = (merged.get("PROJECT_README_ENFORCE") or "1").strip()
26
+ return raw == "1"
27
+
28
+
29
+ def main() -> int:
30
+ parser = argparse.ArgumentParser(
31
+ description="Validate project README catalog coverage vs backlog (DEC-0083)."
32
+ )
33
+ parser.add_argument(
34
+ "--repo",
35
+ default=_REPO_ROOT,
36
+ help="Target repository root (default: parent of scripts/).",
37
+ )
38
+ parser.add_argument(
39
+ "--backlog",
40
+ default=None,
41
+ help="Backlog file (default: docs/product/backlog.md under --repo).",
42
+ )
43
+ parser.add_argument(
44
+ "--self-test",
45
+ action="store_true",
46
+ help="Run sentinel matrix + schema stability checks.",
47
+ )
48
+ parser.add_argument(
49
+ "--report",
50
+ action="store_true",
51
+ help="Emit stable JSON report to stdout.",
52
+ )
53
+ parser.add_argument(
54
+ "--audit-out",
55
+ metavar="PATH",
56
+ default=None,
57
+ help="Write gap audit artifact (JSON) to PATH.",
58
+ )
59
+ parser.add_argument(
60
+ "--enforce",
61
+ action="store_true",
62
+ help="Blocking mode (required for /release when PROJECT_README_ENFORCE=1).",
63
+ )
64
+ parser.add_argument(
65
+ "--no-kit-skip",
66
+ action="store_true",
67
+ help="Validate root even when FRAMEWORK_KIT_REPO=1.",
68
+ )
69
+ args = parser.parse_args()
70
+
71
+ if args.self_test:
72
+ try:
73
+ prc.self_test_sentinel_matrix()
74
+ prc.self_test_report_schema()
75
+ except AssertionError as exc:
76
+ print(f"self-test failed: {exc}", file=sys.stderr)
77
+ return 2
78
+ print("[PROJECT_README_COVERAGE_SELF_TEST_OK]")
79
+ return 0
80
+
81
+ target = os.path.abspath(args.repo)
82
+ backlog = args.backlog or os.path.join(target, "docs", "product", "backlog.md")
83
+ if not os.path.isfile(backlog):
84
+ print(
85
+ f"{prc.REASON_INPUT_INVALID}: backlog not found: {backlog}",
86
+ file=sys.stderr,
87
+ )
88
+ return 2
89
+
90
+ merged, _paths = installer.merge_scratchpad_layers(target)
91
+ enforce = args.enforce or _enforce_flag(merged)
92
+
93
+ if prc.is_framework_kit_repo(merged) and not args.no_kit_skip and not args.enforce:
94
+ report = {
95
+ "catalog_marker_present": False,
96
+ "coverage_missing": [],
97
+ "coverage_present": [],
98
+ "coverage_total": 0,
99
+ "framework_paths_excluded": list(prc.FRAMEWORK_PATHS_EXCLUDED),
100
+ "gaps": [],
101
+ "kit_repo_skipped": True,
102
+ "report_schema_version": prc.REPORT_SCHEMA_VERSION,
103
+ "repo_root": ".",
104
+ "status": "PASS",
105
+ }
106
+ stderr_lines: list[str] = []
107
+ else:
108
+ report, stderr_lines = prc.build_report(
109
+ target,
110
+ backlog,
111
+ enforce=enforce,
112
+ merged=merged,
113
+ no_kit_skip=args.no_kit_skip,
114
+ )
115
+
116
+ if args.audit_out:
117
+ audit_path = args.audit_out
118
+ if not os.path.isabs(audit_path):
119
+ audit_path = os.path.join(target, audit_path)
120
+ os.makedirs(os.path.dirname(audit_path) or ".", exist_ok=True)
121
+ audit_obj = {
122
+ "audit_schema_version": 1,
123
+ "coverage_total": report["coverage_total"],
124
+ "gaps": report["gaps"],
125
+ "items": report["coverage_present"] + report["coverage_missing"],
126
+ "status": report["status"],
127
+ }
128
+ with open(audit_path, "w", encoding="utf-8", newline="\n") as f:
129
+ f.write(prc.canonical_json(audit_obj))
130
+
131
+ blocking = bool(stderr_lines) or report["status"] != "PASS"
132
+
133
+ if args.report or not args.audit_out:
134
+ sys.stdout.write(prc.canonical_json(report))
135
+
136
+ if blocking and (args.enforce or args.report):
137
+ print(prc.REASON_BLOCKED, file=sys.stderr)
138
+ for line in stderr_lines:
139
+ print(line, file=sys.stderr)
140
+ return 1
141
+
142
+ if blocking:
143
+ for line in stderr_lines:
144
+ print(line, file=sys.stderr)
145
+ return 1 if args.enforce else 0
146
+
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())