sarathi-sdlc 0.1.0__py3-none-any.whl

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 (118) hide show
  1. sarathi_sdlc/__init__.py +10 -0
  2. sarathi_sdlc/__main__.py +3 -0
  3. sarathi_sdlc/_bundle/CHANGELOG.md +134 -0
  4. sarathi_sdlc/_bundle/checkers/approvals.py +379 -0
  5. sarathi_sdlc/_bundle/checkers/check_code.py +256 -0
  6. sarathi_sdlc/_bundle/checkers/check_design.py +516 -0
  7. sarathi_sdlc/_bundle/checkers/check_plan.py +635 -0
  8. sarathi_sdlc/_bundle/checkers/check_spec.py +478 -0
  9. sarathi_sdlc/_bundle/checkers/complexity.py +78 -0
  10. sarathi_sdlc/_bundle/checkers/markdown_structure.py +35 -0
  11. sarathi_sdlc/_bundle/checkers/render_workflow_status.py +2397 -0
  12. sarathi_sdlc/_bundle/checkers/schemas.py +77 -0
  13. sarathi_sdlc/_bundle/checkers/waves.py +202 -0
  14. sarathi_sdlc/_bundle/docs/approval-gates.md +141 -0
  15. sarathi_sdlc/_bundle/docs/artifact-contracts.md +199 -0
  16. sarathi_sdlc/_bundle/docs/artifact-formatting.md +29 -0
  17. sarathi_sdlc/_bundle/docs/assurance-profiles.md +90 -0
  18. sarathi_sdlc/_bundle/docs/bootstrap-instructions.md +78 -0
  19. sarathi_sdlc/_bundle/docs/cleanup-pass.md +64 -0
  20. sarathi_sdlc/_bundle/docs/cross-cutting-concerns.md +20 -0
  21. sarathi_sdlc/_bundle/docs/feedback-and-learning.md +123 -0
  22. sarathi_sdlc/_bundle/docs/process-maintenance.md +91 -0
  23. sarathi_sdlc/_bundle/docs/progressive-disclosure.md +105 -0
  24. sarathi_sdlc/_bundle/docs/project-entry.md +156 -0
  25. sarathi_sdlc/_bundle/docs/release-process.md +119 -0
  26. sarathi_sdlc/_bundle/docs/review-verification-checklist.md +63 -0
  27. sarathi_sdlc/_bundle/docs/sarathi.html +987 -0
  28. sarathi_sdlc/_bundle/docs/simplicity-first.md +119 -0
  29. sarathi_sdlc/_bundle/docs/simplify-pass.md +68 -0
  30. sarathi_sdlc/_bundle/docs/slug-id-migration.md +89 -0
  31. sarathi_sdlc/_bundle/docs/srs-authoring.md +161 -0
  32. sarathi_sdlc/_bundle/docs/test-ownership.md +68 -0
  33. sarathi_sdlc/_bundle/docs/work-decomposition.md +104 -0
  34. sarathi_sdlc/_bundle/docs/work-in-progress.md +125 -0
  35. sarathi_sdlc/_bundle/docs/workflow-status.md +230 -0
  36. sarathi_sdlc/_bundle/prompts/code-assess.prompt.md +53 -0
  37. sarathi_sdlc/_bundle/prompts/code-create.prompt.md +85 -0
  38. sarathi_sdlc/_bundle/prompts/code-review.prompt.md +46 -0
  39. sarathi_sdlc/_bundle/prompts/code-verify.prompt.md +72 -0
  40. sarathi_sdlc/_bundle/prompts/design-assess.prompt.md +32 -0
  41. sarathi_sdlc/_bundle/prompts/design-create.prompt.md +90 -0
  42. sarathi_sdlc/_bundle/prompts/design-review.prompt.md +43 -0
  43. sarathi_sdlc/_bundle/prompts/design-verify.prompt.md +90 -0
  44. sarathi_sdlc/_bundle/prompts/plan-assess.prompt.md +40 -0
  45. sarathi_sdlc/_bundle/prompts/plan-create.prompt.md +105 -0
  46. sarathi_sdlc/_bundle/prompts/plan-review.prompt.md +42 -0
  47. sarathi_sdlc/_bundle/prompts/plan-verify.prompt.md +99 -0
  48. sarathi_sdlc/_bundle/prompts/spec-assess.prompt.md +32 -0
  49. sarathi_sdlc/_bundle/prompts/spec-create.prompt.md +96 -0
  50. sarathi_sdlc/_bundle/prompts/spec-review.prompt.md +39 -0
  51. sarathi_sdlc/_bundle/prompts/spec-verify.prompt.md +85 -0
  52. sarathi_sdlc/_bundle/prompts/workflow-status.prompt.md +41 -0
  53. sarathi_sdlc/_bundle/scripts/check_update.py +16 -0
  54. sarathi_sdlc/_bundle/scripts/install.ps1 +614 -0
  55. sarathi_sdlc/_bundle/scripts/install.sh +617 -0
  56. sarathi_sdlc/_bundle/scripts/pre-commit +22 -0
  57. sarathi_sdlc/_bundle/scripts/verify_release.py +36 -0
  58. sarathi_sdlc/_bundle/skills/sarathi/SKILL.md +154 -0
  59. sarathi_sdlc/_bundle/skills/sarathi/agents/openai.yaml +4 -0
  60. sarathi_sdlc/_bundle/skills/sarathi/checkers/approvals.py +379 -0
  61. sarathi_sdlc/_bundle/skills/sarathi/checkers/check_code.py +256 -0
  62. sarathi_sdlc/_bundle/skills/sarathi/checkers/check_design.py +516 -0
  63. sarathi_sdlc/_bundle/skills/sarathi/checkers/check_plan.py +635 -0
  64. sarathi_sdlc/_bundle/skills/sarathi/checkers/check_spec.py +478 -0
  65. sarathi_sdlc/_bundle/skills/sarathi/checkers/complexity.py +78 -0
  66. sarathi_sdlc/_bundle/skills/sarathi/checkers/markdown_structure.py +35 -0
  67. sarathi_sdlc/_bundle/skills/sarathi/checkers/render_workflow_status.py +2397 -0
  68. sarathi_sdlc/_bundle/skills/sarathi/checkers/schemas.py +77 -0
  69. sarathi_sdlc/_bundle/skills/sarathi/checkers/waves.py +202 -0
  70. sarathi_sdlc/_bundle/skills/sarathi/docs/approval-gates.md +141 -0
  71. sarathi_sdlc/_bundle/skills/sarathi/docs/artifact-contracts.md +199 -0
  72. sarathi_sdlc/_bundle/skills/sarathi/docs/artifact-formatting.md +29 -0
  73. sarathi_sdlc/_bundle/skills/sarathi/docs/assurance-profiles.md +90 -0
  74. sarathi_sdlc/_bundle/skills/sarathi/docs/bootstrap-instructions.md +78 -0
  75. sarathi_sdlc/_bundle/skills/sarathi/docs/cleanup-pass.md +64 -0
  76. sarathi_sdlc/_bundle/skills/sarathi/docs/cross-cutting-concerns.md +20 -0
  77. sarathi_sdlc/_bundle/skills/sarathi/docs/feedback-and-learning.md +123 -0
  78. sarathi_sdlc/_bundle/skills/sarathi/docs/process-maintenance.md +91 -0
  79. sarathi_sdlc/_bundle/skills/sarathi/docs/progressive-disclosure.md +105 -0
  80. sarathi_sdlc/_bundle/skills/sarathi/docs/project-entry.md +156 -0
  81. sarathi_sdlc/_bundle/skills/sarathi/docs/release-process.md +119 -0
  82. sarathi_sdlc/_bundle/skills/sarathi/docs/review-verification-checklist.md +63 -0
  83. sarathi_sdlc/_bundle/skills/sarathi/docs/sarathi.html +987 -0
  84. sarathi_sdlc/_bundle/skills/sarathi/docs/simplicity-first.md +119 -0
  85. sarathi_sdlc/_bundle/skills/sarathi/docs/simplify-pass.md +68 -0
  86. sarathi_sdlc/_bundle/skills/sarathi/docs/slug-id-migration.md +89 -0
  87. sarathi_sdlc/_bundle/skills/sarathi/docs/srs-authoring.md +161 -0
  88. sarathi_sdlc/_bundle/skills/sarathi/docs/test-ownership.md +68 -0
  89. sarathi_sdlc/_bundle/skills/sarathi/docs/work-decomposition.md +104 -0
  90. sarathi_sdlc/_bundle/skills/sarathi/docs/work-in-progress.md +125 -0
  91. sarathi_sdlc/_bundle/skills/sarathi/docs/workflow-status.md +230 -0
  92. sarathi_sdlc/_bundle/skills/sarathi/manifest.json +6 -0
  93. sarathi_sdlc/_bundle/skills/sarathi/prompts/code-assess.prompt.md +53 -0
  94. sarathi_sdlc/_bundle/skills/sarathi/prompts/code-create.prompt.md +85 -0
  95. sarathi_sdlc/_bundle/skills/sarathi/prompts/code-review.prompt.md +46 -0
  96. sarathi_sdlc/_bundle/skills/sarathi/prompts/code-verify.prompt.md +72 -0
  97. sarathi_sdlc/_bundle/skills/sarathi/prompts/design-assess.prompt.md +32 -0
  98. sarathi_sdlc/_bundle/skills/sarathi/prompts/design-create.prompt.md +90 -0
  99. sarathi_sdlc/_bundle/skills/sarathi/prompts/design-review.prompt.md +43 -0
  100. sarathi_sdlc/_bundle/skills/sarathi/prompts/design-verify.prompt.md +90 -0
  101. sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-assess.prompt.md +40 -0
  102. sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-create.prompt.md +105 -0
  103. sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-review.prompt.md +42 -0
  104. sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-verify.prompt.md +99 -0
  105. sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-assess.prompt.md +32 -0
  106. sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-create.prompt.md +96 -0
  107. sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-review.prompt.md +39 -0
  108. sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-verify.prompt.md +85 -0
  109. sarathi_sdlc/_bundle/skills/sarathi/prompts/workflow-status.prompt.md +41 -0
  110. sarathi_sdlc/_bundle/skills/sarathi/scripts/check_update.py +138 -0
  111. sarathi_sdlc/_bundle/skills/srs-authoring/SKILL.md +45 -0
  112. sarathi_sdlc/_bundle/skills/srs-authoring/agents/openai.yaml +4 -0
  113. sarathi_sdlc/_bundle/skills/srs-authoring/references/srs-quality.md +160 -0
  114. sarathi_sdlc/cli.py +111 -0
  115. sarathi_sdlc-0.1.0.dist-info/METADATA +566 -0
  116. sarathi_sdlc-0.1.0.dist-info/RECORD +118 -0
  117. sarathi_sdlc-0.1.0.dist-info/WHEEL +4 -0
  118. sarathi_sdlc-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,10 @@
1
+ """Sarathi SDLC distribution support."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("sarathi-sdlc")
7
+ except PackageNotFoundError: # Source checkout without an installed distribution.
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from sarathi_sdlc.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ # Changelog
2
+
3
+ All notable Sarathi changes should be recorded here.
4
+
5
+ This project follows a Keep-a-Changelog style format with `Added`, `Changed`,
6
+ `Fixed`, `Deprecated`, `Removed`, `Security`, and `Docs` headings as needed.
7
+ Release tags use `vMAJOR.MINOR.PATCH` and should match `pyproject.toml`.
8
+
9
+ ## Unreleased
10
+
11
+ ## 0.1.0 - 2026-07-18
12
+
13
+ ### Added
14
+
15
+ - Distribute Sarathi as the `sarathi-sdlc` Python package with a CLI that installs the
16
+ existing cross-platform skill, prompt, and checker targets from a wheel.
17
+ - Give installed skills explicit version metadata and a cached, fail-open PyPI update check
18
+ with an environment-variable opt-out.
19
+ - Publish tagged releases through a gated GitHub Actions Trusted Publisher workflow, then
20
+ create a GitHub Release with the verified wheel and source distribution attached.
21
+ - Show ordered `Wave N` labels beside scheduled child work in the workflow tree, show each
22
+ slice's PR state directly beneath its document chain, and let the compact parent-approval
23
+ status open a dialog with each approval record, stale hash prefixes, and the exact next
24
+ approval needed. Keep wave detail in the expanded tree row instead of a second page section.
25
+ - Add a delivery-progress status summary and feature, slice, wave, and text filters that
26
+ retain enough hierarchy to navigate from product progress to the detailed allocation tree.
27
+
28
+ - Add Lean, Standard, and High-assurance delivery profiles with context-triggered assurance
29
+ modules while preserving one feedback-driven lifecycle.
30
+ - Add a hard simplicity policy: process/product architecture separation, brownfield oracle
31
+ reuse, evidence-before-generalization, deletion-first review, conceptual complexity
32
+ budgets, and a three-PR default for bounded implementation slices.
33
+ - Add deterministic instruction budgets and a package-extraction regression example that
34
+ collapses speculative machinery into current contracts, consumer integration, and real
35
+ compatibility evidence.
36
+ - Add ordered wave declarations and a workflow-status projection: Breakdown plans assign
37
+ `WORK-*` members to `WAVE-*` sequences, while hash-current wave checkpoints preserve
38
+ completed feedback and parent-document impact evidence.
39
+ - Add an iterative feedback-and-learning policy: code-ready slices declare learning and
40
+ feedback targets, code assessment records honest feedback status and ancestor impact, and
41
+ the static process guide shows the inspect/adapt loop.
42
+ - Add bounded learning-wave guidance for agent parallelism, distinguishing preferred
43
+ intra-slice work, independent concurrent slices, and exceptional speculative downstream
44
+ work through execution, learning, and integration dependencies.
45
+ - Show the current learning target, feedback state, active wave and slices, WIP limit,
46
+ invalidation result, ancestor impact, and stop/replan triggers in workflow-status HTML;
47
+ preserve completed-slice learning evidence in hash-current code assessments.
48
+ - Add the top-level changelog and maintainer release/tagging process.
49
+ - Add a deterministic workflow-status HTML renderer and `/workflow-status` command that
50
+ visualize real artifact gates, known-unknown decomposition, PR slices, and mapped test
51
+ evidence as the same branching Spec/Design/Plan/Code tree used by the linked static process
52
+ guide.
53
+
54
+ ### Fixed
55
+
56
+ - Resolve transitive verify/review prompts from the sibling Sarathi bundle in direct stage
57
+ aliases, with project-install regression coverage.
58
+ - Install complete executable checker bundles, including shared parser/support modules.
59
+ - Require every delivery plan to declare complete ordered learning waves instead of
60
+ allowing an absent wave section to pass structurally.
61
+ - Separate four-plus-PR exception approval into `plan.complexity-approved`, allowing draft
62
+ verification before the targeted approval and reserving `plan.approved` for code entry.
63
+ - Match complexity approvals to Slice/change Plan artifacts, reject auto-approval, and let
64
+ later valid reapprovals supersede stale or ineligible earlier records.
65
+ - Validate an exact structured complexity budget and declared PR count instead of accepting
66
+ any non-empty one-line mention.
67
+ - Restore explicit TDD-exception categories, scope, replacement evidence, and qualitative
68
+ safety boundaries while replacing lexical Red/Green matching with labeled contracts.
69
+ - Ignore fenced Markdown examples when parsing complexity budgets, learning waves, and TDD
70
+ fields; require exact TDD-exception category values.
71
+ - Use one canonical `MILE|WORK|PR-AREA-NAME` grammar in plan verification and workflow
72
+ rendering; reject one-token and extra-token IDs, avoid valid-prefix matches, and display
73
+ malformed allocations as excluded repair warnings.
74
+ - Prevent status and process-guide nodes from collapsing below their content height in
75
+ mobile column layouts; add Chromium assertions for clipping, overlap, and horizontal
76
+ overflow at mobile and desktop viewports.
77
+
78
+ ### Changed
79
+
80
+ - Move wave scheduling to Breakdown plans: they group `WORK-*` children, while an
81
+ Implementation plan contains the PRs for one child. Plan and status checks enforce that
82
+ separation.
83
+ - Show feature-owned and product-owned slices separately in workflow status, including
84
+ nested allocation paths and external artifact links where configured.
85
+
86
+ - Rewrite the routing skill, stage prompts, core process docs, static guide, and workflow
87
+ status copy in plain language while preserving checker fields, approval boundaries,
88
+ independent assessment, TDD, feedback, and safety rules.
89
+ - Consolidate shared policy into triggered references and reduce the canonical stage prompt
90
+ surface from roughly 5,000 lines to about 1,000 lines.
91
+ - Make simplify passes capable of requiring upstream spec/design/plan revision when an
92
+ accepted artifact is itself overbuilt.
93
+ - Distinguish mapped test evidence from assessed or completed code: hash-current passing
94
+ code-assessment entries display `Assessed`, while hash-current `code_slice.approved`
95
+ records display `Completed`.
96
+ - Publish installed skill manifests by atomic replacement so agents cannot observe a
97
+ truncated YAML frontmatter block while an installation is running.
98
+ - Avoid treating `.sdlc/test-traceability.yaml` path references as malformed
99
+ `TEST-*` IDs during plan/code structural checks.
100
+ - Define approval as permission for the next learning step rather than artifact freeze;
101
+ require post-slice revision decisions for affected specs, designs, plans, integration
102
+ work, and process tools before learning-dependent work continues.
103
+ - Rework live workflow status around a compact executive summary and progressively
104
+ disclosed allocation tree: the active branch opens by default, inactive branches remain
105
+ collapsed, and green/amber/gray icon states distinguish approval, work in progress, and
106
+ work not started without implying percentage complete.
107
+ - Clarify cross-scope test ownership: ancestor product/feature acceptance and integration
108
+ obligations must be allocated to code-ready descendant leaves instead of disappearing
109
+ during decomposition or becoming a final big-bang test phase.
110
+ - Define `WORK-*` as a parent Breakdown-plan allocation to an explicit child scope and
111
+ artifact chain; distinguish product-to-feature, feature-to-slice, and justified
112
+ product-to-integration-slice decomposition in prompts, docs, status views, and mechanical
113
+ plan checks.
114
+ - Replace the verbose static process guide with concise PR-sized and decomposable-product
115
+ trees that show feature, slice, and product-integration test leaves and their statuses;
116
+ encode artifact type by node background and work scope by explicit level tags, with
117
+ legend swatches that display the full node fill and accent edge.
118
+
119
+ ### Removed
120
+
121
+ - Remove source-hash snapshot and provenance-table UI from workflow status; hashes remain
122
+ available to deterministic checks without becoming status-page content.
123
+ - Remove the workflow-and-learning diagnostics disclosure; delivery status is conveyed by the
124
+ progress summary, workflow tree, approval dialog, and only real plan-check warnings.
125
+ - Remove all PR, diff, module, and source-file line-count options, metrics, warnings,
126
+ reports, tests, and planning guidance. Reviewability now uses cohesion, conceptual
127
+ complexity, touch scope, evidence, rollback, and learning boundaries.
128
+ - Remove superseded private critical-review snapshots from active documentation.
129
+
130
+ ## Pre-Changelog History
131
+
132
+ Sarathi existed before this changelog was introduced. Earlier history is
133
+ available in Git, including the prompt, skill, checker, installer, brownfield
134
+ adoption, SRS authoring, cleanup, simplify, and process-quality gate changes.
@@ -0,0 +1,379 @@
1
+ """Approval ledger helpers for deterministic SDLC gates.
2
+
3
+ The approval ledger is intentionally local and tool-agnostic. Projects may store
4
+ ticket or PR links as evidence, but the mechanical gate only trusts local YAML,
5
+ UTC timestamps, and artifact hashes. The ledger proves that a local attestation
6
+ record is well-formed and hash-current; it does not prove human intent or consent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ UTC_TS = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
18
+ APPROVED_STATUSES = {"approved", "auto-approved"}
19
+
20
+
21
+ def _strip_comment(line: str) -> str:
22
+ in_quote: str | None = None
23
+ for i, ch in enumerate(line):
24
+ if ch in {"'", '"'} and (i == 0 or line[i - 1] != "\\"):
25
+ in_quote = None if in_quote == ch else ch
26
+ if ch == "#" and in_quote is None:
27
+ return line[:i]
28
+ return line
29
+
30
+
31
+ def _scalar(raw: str) -> Any:
32
+ value = raw.strip()
33
+ if not value:
34
+ return ""
35
+ if (value[0], value[-1]) in {('"', '"'), ("'", "'")}:
36
+ return value[1:-1]
37
+ if value in {"true", "True"}:
38
+ return True
39
+ if value in {"false", "False"}:
40
+ return False
41
+ if value in {"null", "Null", "None", "~"}:
42
+ return None
43
+ if value.startswith("[") and value.endswith("]"):
44
+ inner = value[1:-1].strip()
45
+ if not inner:
46
+ return []
47
+ return [_scalar(part.strip()) for part in inner.split(",")]
48
+ if re.fullmatch(r"-?\d+", value):
49
+ return int(value)
50
+ return value
51
+
52
+
53
+ def _split_key_value(text: str) -> tuple[str, str]:
54
+ if ":" not in text:
55
+ raise ValueError(f"Expected key/value pair: {text!r}")
56
+ key, value = text.split(":", 1)
57
+ return key.strip(), value.strip()
58
+
59
+
60
+ def _yaml_subset(text: str) -> Any:
61
+ """Parse the small YAML subset used by approval ledgers.
62
+
63
+ Supports nested mappings, lists of mappings, scalars, quoted strings,
64
+ booleans, integers, nulls, and simple inline lists. It is not a general YAML
65
+ parser.
66
+ """
67
+
68
+ lines: list[tuple[int, str]] = []
69
+ for raw in text.splitlines():
70
+ cleaned = _strip_comment(raw).rstrip()
71
+ if not cleaned.strip():
72
+ continue
73
+ indent = len(cleaned) - len(cleaned.lstrip(" "))
74
+ lines.append((indent, cleaned.strip()))
75
+
76
+ def parse_block(index: int, indent: int) -> tuple[Any, int]:
77
+ if index >= len(lines):
78
+ return {}, index
79
+ if lines[index][1].startswith("- "):
80
+ return parse_list(index, indent)
81
+ return parse_dict(index, indent)
82
+
83
+ def parse_dict(index: int, indent: int) -> tuple[dict[str, Any], int]:
84
+ out: dict[str, Any] = {}
85
+ while index < len(lines):
86
+ line_indent, content = lines[index]
87
+ if line_indent < indent or content.startswith("- "):
88
+ break
89
+ if line_indent > indent:
90
+ raise ValueError(f"Unexpected indentation near: {content!r}")
91
+ key, value = _split_key_value(content)
92
+ index += 1
93
+ if value:
94
+ out[key] = _scalar(value)
95
+ elif index < len(lines) and lines[index][0] > line_indent:
96
+ out[key], index = parse_block(index, lines[index][0])
97
+ else:
98
+ out[key] = None
99
+ return out, index
100
+
101
+ def parse_list(index: int, indent: int) -> tuple[list[Any], int]:
102
+ out: list[Any] = []
103
+ while index < len(lines):
104
+ line_indent, content = lines[index]
105
+ if line_indent < indent or not content.startswith("- "):
106
+ break
107
+ if line_indent > indent:
108
+ raise ValueError(f"Unexpected list indentation near: {content!r}")
109
+ item_text = content[2:].strip()
110
+ index += 1
111
+ if not item_text:
112
+ if index < len(lines) and lines[index][0] > line_indent:
113
+ item, index = parse_block(index, lines[index][0])
114
+ else:
115
+ item = None
116
+ elif ":" in item_text:
117
+ key, value = _split_key_value(item_text)
118
+ item = {key: _scalar(value)} if value else {key: None}
119
+ if not value and index < len(lines) and lines[index][0] > line_indent:
120
+ item[key], index = parse_block(index, lines[index][0])
121
+ if index < len(lines) and lines[index][0] > line_indent:
122
+ extra, index = parse_dict(index, lines[index][0])
123
+ item.update(extra)
124
+ else:
125
+ item = _scalar(item_text)
126
+ out.append(item)
127
+ return out, index
128
+
129
+ if not lines:
130
+ return {}
131
+ parsed, final = parse_block(0, lines[0][0])
132
+ if final != len(lines):
133
+ raise ValueError("Could not parse complete YAML subset")
134
+ return parsed
135
+
136
+
137
+ def load_yaml_file(path: Path) -> Any:
138
+ text = path.read_text(encoding="utf-8")
139
+ return _yaml_subset(text)
140
+
141
+
142
+ def sha256_file(path: Path) -> str | None:
143
+ if not path.exists() or not path.is_file():
144
+ return None
145
+ digest = hashlib.sha256()
146
+ with path.open("rb") as handle:
147
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
148
+ digest.update(chunk)
149
+ return digest.hexdigest()
150
+
151
+
152
+ def valid_utc_timestamp(value: Any) -> bool:
153
+ if not isinstance(value, str) or not UTC_TS.fullmatch(value):
154
+ return False
155
+ try:
156
+ datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
157
+ except ValueError:
158
+ return False
159
+ return True
160
+
161
+
162
+ def _as_list(value: Any) -> list[Any]:
163
+ if value is None:
164
+ return []
165
+ return value if isinstance(value, list) else [value]
166
+
167
+
168
+ def _norm_rel(path: str | Path) -> str:
169
+ return Path(path).as_posix().lstrip("./")
170
+
171
+
172
+ def _norm_project_path(project_root: Path, path: str | Path) -> str:
173
+ candidate = Path(path)
174
+ if candidate.is_absolute():
175
+ try:
176
+ return candidate.resolve().relative_to(project_root.resolve()).as_posix()
177
+ except ValueError:
178
+ return candidate.as_posix()
179
+ return _norm_rel(candidate)
180
+
181
+
182
+ def _policy_allows(
183
+ record: dict[str, Any],
184
+ policy: dict[str, Any],
185
+ now: datetime | None = None,
186
+ ) -> list[str]:
187
+ issues: list[str] = []
188
+ auto = policy.get("auto_approval") if isinstance(policy, dict) else None
189
+ if not isinstance(auto, dict) or auto.get("enabled") is not True:
190
+ return ["auto approval used but not enabled in gates policy"]
191
+ gate = record.get("gate")
192
+ scope = record.get("scope")
193
+ allowed_gates = {str(x) for x in _as_list(auto.get("allowed_gates"))}
194
+ allowed_scopes = {str(x) for x in _as_list(auto.get("allowed_scopes"))}
195
+ forbidden_gates = {str(x) for x in _as_list(auto.get("forbidden_gates"))}
196
+ if gate in forbidden_gates:
197
+ issues.append(f"gate {gate} is forbidden for auto approval")
198
+ if "*" not in allowed_gates and gate not in allowed_gates:
199
+ issues.append(f"gate {gate} is not allowed for auto approval")
200
+ if "*" not in allowed_scopes and scope not in allowed_scopes:
201
+ issues.append(f"scope {scope} is not allowed for auto approval")
202
+ expires_at = auto.get("expires_at")
203
+ if not valid_utc_timestamp(expires_at):
204
+ issues.append("auto approval policy expires_at must be UTC ISO-8601")
205
+ else:
206
+ expiry = datetime.strptime(str(expires_at), "%Y-%m-%dT%H:%M:%SZ").replace(
207
+ tzinfo=UTC
208
+ )
209
+ if (now or datetime.now(UTC)) >= expiry:
210
+ issues.append("auto approval policy is expired")
211
+ return issues
212
+
213
+
214
+ def validate_approval_record(
215
+ record: Any,
216
+ project_root: Path,
217
+ gates_policy: dict[str, Any] | None = None,
218
+ ) -> list[str]:
219
+ issues: list[str] = []
220
+ if not isinstance(record, dict):
221
+ return ["approval record must be a mapping"]
222
+ for key in (
223
+ "id",
224
+ "gate",
225
+ "scope",
226
+ "artifact",
227
+ "status",
228
+ "approved_by",
229
+ "approved_at",
230
+ ):
231
+ if not record.get(key):
232
+ issues.append(f"missing {key}")
233
+ if record.get("status") not in APPROVED_STATUSES:
234
+ issues.append("status must be approved or auto-approved")
235
+ if not valid_utc_timestamp(record.get("approved_at")):
236
+ issues.append("approved_at must be UTC ISO-8601 like 2026-07-01T14:32:18Z")
237
+ artifact = record.get("artifact")
238
+ if not isinstance(artifact, dict):
239
+ issues.append("artifact must be a mapping")
240
+ return issues
241
+ for key in ("kind", "path", "sha256"):
242
+ if not artifact.get(key):
243
+ issues.append(f"artifact missing {key}")
244
+ if artifact.get("kind") == "marker-inventory":
245
+ if not re.fullmatch(r"[0-9a-f]{64}", str(artifact.get("sha256", ""))):
246
+ issues.append(
247
+ "marker inventory sha256 must be a lowercase SHA-256 hex digest"
248
+ )
249
+ else:
250
+ artifact_path = project_root / str(artifact.get("path", ""))
251
+ actual_hash = sha256_file(artifact_path)
252
+ if actual_hash is None:
253
+ issues.append(f"artifact path does not exist: {artifact.get('path')}")
254
+ elif artifact.get("sha256") != actual_hash:
255
+ issues.append(f"artifact hash is stale: {artifact.get('path')}")
256
+ if record.get("status") == "auto-approved":
257
+ issues.extend(_policy_allows(record, gates_policy or {}))
258
+ return issues
259
+
260
+
261
+ def load_approval_context(
262
+ project_root: Path,
263
+ approvals_path: str = ".sdlc/approvals.yaml",
264
+ gates_path: str = ".sdlc/gates.yaml",
265
+ ) -> dict[str, Any]:
266
+ approvals_file = project_root / approvals_path
267
+ gates_file = project_root / gates_path
268
+ context: dict[str, Any] = {
269
+ "approvals_path": approvals_path,
270
+ "gates_path": gates_path,
271
+ "exists": approvals_file.exists(),
272
+ "records": [],
273
+ "invalid_records": [],
274
+ "load_error": None,
275
+ }
276
+ gates_policy: dict[str, Any] = {}
277
+ try:
278
+ if gates_file.exists():
279
+ loaded_policy = load_yaml_file(gates_file)
280
+ gates_policy = loaded_policy if isinstance(loaded_policy, dict) else {}
281
+ context["gates_policy_exists"] = gates_file.exists()
282
+ context["gates_policy"] = gates_policy
283
+ if not approvals_file.exists():
284
+ return context
285
+ data = load_yaml_file(approvals_file)
286
+ records = data.get("approvals") if isinstance(data, dict) else None
287
+ if not isinstance(records, list):
288
+ context["load_error"] = "approvals must be a list"
289
+ return context
290
+ context["records"] = records
291
+ invalid = []
292
+ for record in records:
293
+ issues = validate_approval_record(record, project_root, gates_policy)
294
+ if issues:
295
+ invalid.append(
296
+ {
297
+ "id": record.get("id") if isinstance(record, dict) else None,
298
+ "issues": issues,
299
+ }
300
+ )
301
+ context["invalid_records"] = invalid
302
+ except Exception as exc: # noqa: BLE001 - checker reports deterministic load errors.
303
+ context["load_error"] = str(exc)
304
+ return context
305
+
306
+
307
+ def approval_requirement(
308
+ context: dict[str, Any],
309
+ project_root: Path,
310
+ gate: str,
311
+ artifact_path: str | Path,
312
+ scope: str | None = None,
313
+ artifact_kind: str | None = None,
314
+ expected_sha256: str | None = None,
315
+ allowed_statuses: set[str] | None = None,
316
+ ) -> dict[str, Any]:
317
+ wanted = _norm_project_path(project_root, artifact_path)
318
+ result: dict[str, Any] = {
319
+ "gate": gate,
320
+ "artifact": wanted,
321
+ "scope": scope,
322
+ "approved": False,
323
+ "approval_id": None,
324
+ "status": None,
325
+ "evidence_semantics": (
326
+ "hash-current local attestation, not proof of human consent"
327
+ ),
328
+ "issues": [],
329
+ }
330
+ if context.get("load_error"):
331
+ result["issues"].append(f"approval ledger load failed: {context['load_error']}")
332
+ return result
333
+ if not context.get("exists"):
334
+ result["issues"].append(f"approval ledger missing: {context['approvals_path']}")
335
+ return result
336
+ invalid_by_id = {
337
+ item["id"]: item["issues"] for item in context.get("invalid_records", [])
338
+ }
339
+ candidate_issues: list[str] = []
340
+ for record in context.get("records", []):
341
+ if not isinstance(record, dict):
342
+ continue
343
+ artifact = record.get("artifact")
344
+ if not isinstance(artifact, dict):
345
+ continue
346
+ if record.get("gate") != gate:
347
+ continue
348
+ if scope is not None and record.get("scope") != scope:
349
+ continue
350
+ if artifact_kind is not None and artifact.get("kind") != artifact_kind:
351
+ continue
352
+ if _norm_project_path(project_root, str(artifact.get("path", ""))) != wanted:
353
+ continue
354
+ if expected_sha256 is not None and artifact.get("sha256") != expected_sha256:
355
+ continue
356
+ result["approval_id"] = record.get("id")
357
+ result["status"] = record.get("status")
358
+ issues = list(invalid_by_id.get(record.get("id"), []))
359
+ if (
360
+ allowed_statuses is not None
361
+ and record.get("status") not in allowed_statuses
362
+ ):
363
+ allowed = ", ".join(sorted(allowed_statuses))
364
+ issues.append(f"approval status must be one of: {allowed}")
365
+ if issues:
366
+ candidate_issues.extend(issues)
367
+ continue
368
+ result["approved"] = True
369
+ result["issues"] = []
370
+ return result
371
+ if candidate_issues:
372
+ result["issues"] = list(dict.fromkeys(candidate_issues))
373
+ else:
374
+ result["issues"].append("matching approval not found")
375
+ return result
376
+
377
+
378
+ def approval_gate_passed(requirements: list[dict[str, Any]]) -> bool:
379
+ return all(item.get("approved") for item in requirements)