seshat-bi 0.2.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 (190) hide show
  1. retail/__init__.py +3 -0
  2. retail/cli.py +6 -0
  3. seshat/__init__.py +1 -0
  4. seshat/agent_next.py +457 -0
  5. seshat/approval_inbox.py +240 -0
  6. seshat/approver_view.py +343 -0
  7. seshat/artifact_identity.py +49 -0
  8. seshat/benchmark/__init__.py +7 -0
  9. seshat/benchmark/model.py +118 -0
  10. seshat/benchmark/reference.py +42 -0
  11. seshat/benchmark/render.py +85 -0
  12. seshat/benchmark/runner.py +291 -0
  13. seshat/blocker_explainer.py +184 -0
  14. seshat/blueprint_preview.py +368 -0
  15. seshat/capability_feeders.py +230 -0
  16. seshat/capability_inventory.py +289 -0
  17. seshat/cli/__init__.py +319 -0
  18. seshat/cli/__main__.py +15 -0
  19. seshat/cli/commands/__init__.py +7 -0
  20. seshat/cli/commands/approvals.py +38 -0
  21. seshat/cli/commands/approver_view.py +21 -0
  22. seshat/cli/commands/benchmark.py +86 -0
  23. seshat/cli/commands/blockers.py +34 -0
  24. seshat/cli/commands/dashboard_planner.py +53 -0
  25. seshat/cli/commands/drift.py +219 -0
  26. seshat/cli/commands/evidence_pack.py +36 -0
  27. seshat/cli/commands/explorer.py +47 -0
  28. seshat/cli/commands/gap_detector.py +25 -0
  29. seshat/cli/commands/generate.py +100 -0
  30. seshat/cli/commands/init.py +45 -0
  31. seshat/cli/commands/init_project.py +44 -0
  32. seshat/cli/commands/kit_lint.py +33 -0
  33. seshat/cli/commands/manifest.py +17 -0
  34. seshat/cli/commands/next.py +106 -0
  35. seshat/cli/commands/pack.py +86 -0
  36. seshat/cli/commands/passport.py +108 -0
  37. seshat/cli/commands/pii_notice.py +31 -0
  38. seshat/cli/commands/scaffold.py +60 -0
  39. seshat/cli/commands/semantic.py +95 -0
  40. seshat/cli/commands/severity_posture.py +17 -0
  41. seshat/cli/commands/status.py +52 -0
  42. seshat/cli/commands/validate.py +122 -0
  43. seshat/cli/commands/value_check.py +315 -0
  44. seshat/cli/guards.py +28 -0
  45. seshat/cli/parser.py +1003 -0
  46. seshat/cli/parser_ecosystem.py +256 -0
  47. seshat/color.py +127 -0
  48. seshat/compass_project.py +165 -0
  49. seshat/core.py +92 -0
  50. seshat/coverage_status.py +56 -0
  51. seshat/dashboard_coordinator.py +457 -0
  52. seshat/dashboard_planner.py +628 -0
  53. seshat/dax_gen.py +327 -0
  54. seshat/decision_gate.py +308 -0
  55. seshat/decision_store.py +413 -0
  56. seshat/demo/__init__.py +69 -0
  57. seshat/demo/assets/demo.css +75 -0
  58. seshat/demo/assets/demo.js +10 -0
  59. seshat/demo/fixtures.py +101 -0
  60. seshat/demo/html_report.py +144 -0
  61. seshat/demo/init.py +30 -0
  62. seshat/demo/live.py +62 -0
  63. seshat/demo/load.py +78 -0
  64. seshat/demo/report.py +133 -0
  65. seshat/demo/resources/demo_sample_orders/assumptions.md +25 -0
  66. seshat/demo/resources/demo_sample_orders/readiness-status.yaml +99 -0
  67. seshat/demo/resources/demo_sample_orders/source-map.yaml +27 -0
  68. seshat/demo/resources/demo_sample_orders/source-profile.md +33 -0
  69. seshat/demo/resources/demo_sample_orders/unresolved-questions.md +26 -0
  70. seshat/demo/resources/demo_sample_orders.csv +25 -0
  71. seshat/demo/resources/seshat-seven-star.svg +1 -0
  72. seshat/demo/run.py +119 -0
  73. seshat/dialect.py +580 -0
  74. seshat/disclosure.py +113 -0
  75. seshat/doctor.py +98 -0
  76. seshat/drift.py +385 -0
  77. seshat/drift_semantics.py +69 -0
  78. seshat/ecosystem_contracts.py +187 -0
  79. seshat/evidence_pack.py +306 -0
  80. seshat/explorer/__init__.py +7 -0
  81. seshat/explorer/assets/explorer.css +84 -0
  82. seshat/explorer/assets/explorer.js +19 -0
  83. seshat/explorer/build.py +407 -0
  84. seshat/fence.py +133 -0
  85. seshat/file_profile.py +438 -0
  86. seshat/gap_detector.py +533 -0
  87. seshat/gitutil.py +75 -0
  88. seshat/governor/__init__.py +5 -0
  89. seshat/governor/mcp_server.py +84 -0
  90. seshat/governor/service.py +232 -0
  91. seshat/identifiers.py +102 -0
  92. seshat/interview_review.py +148 -0
  93. seshat/kit_init.py +139 -0
  94. seshat/kit_lint.py +138 -0
  95. seshat/kpi_answerability.py +301 -0
  96. seshat/kpi_contracts.py +483 -0
  97. seshat/manifest.py +65 -0
  98. seshat/metric_drift.py +473 -0
  99. seshat/packs/__init__.py +10 -0
  100. seshat/packs/loader.py +53 -0
  101. seshat/packs/model.py +112 -0
  102. seshat/packs/scaffold.py +183 -0
  103. seshat/packs/validator.py +413 -0
  104. seshat/passport.py +327 -0
  105. seshat/pbir_compile.py +488 -0
  106. seshat/pbir_geometry.py +238 -0
  107. seshat/pbir_page_background.py +264 -0
  108. seshat/pbir_theme_apply.py +162 -0
  109. seshat/pbir_validate_blueprint.py +519 -0
  110. seshat/pbir_visual_format.py +267 -0
  111. seshat/pii_notice.py +287 -0
  112. seshat/portfolio_enumerate.py +105 -0
  113. seshat/profile.py +169 -0
  114. seshat/readiness_classify.py +90 -0
  115. seshat/readiness_evidence.py +136 -0
  116. seshat/readiness_projection.py +108 -0
  117. seshat/registry.py +24 -0
  118. seshat/release_evidence.py +688 -0
  119. seshat/report_intent.py +129 -0
  120. seshat/review_integration.py +127 -0
  121. seshat/review_pack_export.py +250 -0
  122. seshat/rules/__init__.py +110 -0
  123. seshat/rules/additivity_consistency.py +195 -0
  124. seshat/rules/answerability_reconciler.py +242 -0
  125. seshat/rules/assumption_coherence.py +166 -0
  126. seshat/rules/assumptions.py +122 -0
  127. seshat/rules/comparison_baseline.py +188 -0
  128. seshat/rules/conformed_dimension.py +319 -0
  129. seshat/rules/currency_unit.py +309 -0
  130. seshat/rules/date_spine.py +426 -0
  131. seshat/rules/dax.py +606 -0
  132. seshat/rules/decision_store.py +583 -0
  133. seshat/rules/design_background.py +348 -0
  134. seshat/rules/design_categorical_distinctness.py +136 -0
  135. seshat/rules/design_contrast.py +150 -0
  136. seshat/rules/design_grid_closure.py +183 -0
  137. seshat/rules/design_ramp_deltae.py +110 -0
  138. seshat/rules/design_review_evidence.py +189 -0
  139. seshat/rules/design_routes.py +147 -0
  140. seshat/rules/design_theme.py +169 -0
  141. seshat/rules/design_theme_fidelity.py +421 -0
  142. seshat/rules/design_visual_selfcheck.py +144 -0
  143. seshat/rules/formatting_plan.py +287 -0
  144. seshat/rules/g6.py +77 -0
  145. seshat/rules/git_meta.py +776 -0
  146. seshat/rules/live_surface_boundary.py +94 -0
  147. seshat/rules/never_execute.py +190 -0
  148. seshat/rules/parked_on.py +149 -0
  149. seshat/rules/pbir.py +199 -0
  150. seshat/rules/publish_pack.py +162 -0
  151. seshat/rules/readiness_status.py +464 -0
  152. seshat/rules/reload_idempotency.py +172 -0
  153. seshat/rules/rename_impact_guard.py +405 -0
  154. seshat/rules/report_intent.py +217 -0
  155. seshat/rules/rls_access.py +457 -0
  156. seshat/rules/routes.py +152 -0
  157. seshat/rules/routes_coverage.py +198 -0
  158. seshat/rules/rule_ap1.py +227 -0
  159. seshat/rules/rule_count_claims.py +202 -0
  160. seshat/rules/rule_kp1.py +174 -0
  161. seshat/rules/rule_kr1.py +367 -0
  162. seshat/rules/rule_sf1.py +183 -0
  163. seshat/rules/scorecard.py +246 -0
  164. seshat/rules/snapshot_time_additivity.py +216 -0
  165. seshat/rules/source_data_contract.py +214 -0
  166. seshat/rules/source_freshness.py +228 -0
  167. seshat/rules/sql.py +674 -0
  168. seshat/rules/status_claims.py +161 -0
  169. seshat/run_next.py +444 -0
  170. seshat/runner.py +227 -0
  171. seshat/sarif.py +84 -0
  172. seshat/scaffold.py +542 -0
  173. seshat/semantic.py +75 -0
  174. seshat/semantic_audit.py +516 -0
  175. seshat/severity_posture.py +644 -0
  176. seshat/source_profile_reader.py +167 -0
  177. seshat/sql.py +456 -0
  178. seshat/status_surface.py +138 -0
  179. seshat/theme_compile.py +369 -0
  180. seshat/theme_gen.py +780 -0
  181. seshat/tmdl.py +731 -0
  182. seshat/validate.py +380 -0
  183. seshat/validate_targets.py +143 -0
  184. seshat/value_proxy.py +332 -0
  185. seshat/workspace_init.py +221 -0
  186. seshat_bi-0.2.0.dist-info/METADATA +458 -0
  187. seshat_bi-0.2.0.dist-info/RECORD +190 -0
  188. seshat_bi-0.2.0.dist-info/WHEEL +4 -0
  189. seshat_bi-0.2.0.dist-info/entry_points.txt +3 -0
  190. seshat_bi-0.2.0.dist-info/licenses/LICENSE +201 -0
retail/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Deprecated compatibility package for the renamed :mod:`seshat` module."""
2
+
3
+ from seshat import * # noqa: F403
retail/cli.py ADDED
@@ -0,0 +1,6 @@
1
+ """Deprecated ``python -m retail.cli`` compatibility entry point."""
2
+
3
+ from seshat.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
seshat/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Seshat BI static governance checker."""
seshat/agent_next.py ADDED
@@ -0,0 +1,457 @@
1
+ """Agent-facing next-action document (Seshat Agent-Driven v0.1).
2
+
3
+ ``retail next --format agent`` / repo-level ``retail next --format json`` answer
4
+ the guarded-agent questions in ONE stable document: what stage is this project
5
+ in, what evidence exists, what is blocked, what is the next allowed action, what
6
+ is forbidden, and where must the agent stop.
7
+
8
+ This module is a COMPOSITION, not a second source of truth:
9
+
10
+ - the per-table decision (next action / blocked / approval required /
11
+ terminal pass / input defect) is ``seshat.run_next.build_run_next_response``
12
+ (spec 080), reused verbatim;
13
+ - the recorded evidence/status projection is
14
+ ``seshat.status_surface.build_status_projection`` (spec 109), reused
15
+ verbatim;
16
+ - the gate ordering is the seven-stage spine already fixed in
17
+ ``run_next._STAGE_ORDER``.
18
+
19
+ Contract (same posture as both parents): read-only -- no writes, no DB, no
20
+ network; deterministic -- same committed state, byte-identical document; never
21
+ a numeric readiness value -- only the four categorical statuses plus named
22
+ evidence/blocker strings (hard rule #9, Principle V). When evidence is missing
23
+ the document degrades to the conservative evidence-first action (start at
24
+ Source Ready), never a fabricated stage.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from seshat.run_next import (
33
+ _STAGE_ORDER,
34
+ build_run_next_response,
35
+ )
36
+ from seshat.status_surface import build_status_projection
37
+
38
+ _STAGE_TITLES: dict[str, str] = {
39
+ "source_ready": "Source Ready (Stage 1)",
40
+ "mapping_ready": "Mapping Ready (Stage 2)",
41
+ "silver_ready": "Silver Ready (Stage 3)",
42
+ "gold_ready": "Gold Ready (Stage 4)",
43
+ "semantic_model_ready": "Semantic Model Ready (Stage 5)",
44
+ "dashboard_ready": "Dashboard Ready (Stage 6)",
45
+ "publish_ready": "Publish Ready (Stage 7)",
46
+ }
47
+
48
+ # One sentence per gate, in spine order: work forbidden until that gate passes.
49
+ _GATE_RULES: tuple[tuple[str, str], ...] = (
50
+ (
51
+ "mapping_ready",
52
+ "No silver work (no silver.* SQL) before Mapping Ready passes.",
53
+ ),
54
+ (
55
+ "silver_ready",
56
+ "No gold work (no gold star/mart SQL) before Silver Ready passes.",
57
+ ),
58
+ (
59
+ "gold_ready",
60
+ "No semantic-model work before Gold Ready (live-validated) passes.",
61
+ ),
62
+ (
63
+ "semantic_model_ready",
64
+ "No dashboard work before Semantic Model Ready "
65
+ "(approved metric contracts) passes.",
66
+ ),
67
+ (
68
+ "dashboard_ready",
69
+ "No publish/handoff work before Dashboard Ready passes.",
70
+ ),
71
+ (
72
+ "publish_ready",
73
+ "No live publish before Publish Ready passes; publish execution is "
74
+ "the deferred F016 adapter.",
75
+ ),
76
+ )
77
+
78
+ # Invariants that hold at every stage, including terminal pass.
79
+ _ALWAYS_FORBIDDEN: tuple[str, ...] = (
80
+ "Never self-grant an approval; approvals are named-human actions.",
81
+ "Never fabricate readiness; readiness is status + evidence + blockers, "
82
+ "never a number.",
83
+ "Never run the Power BI execution adapter (F016) from this surface.",
84
+ )
85
+
86
+ _BASE_VALIDATION_COMMANDS: tuple[str, ...] = (
87
+ "python -m seshat.cli check --repo .",
88
+ "python -m seshat.cli status --repo . --format json",
89
+ "python -m seshat.cli next --repo . --format json",
90
+ )
91
+
92
+ _VALIDATION_EXTRAS_BY_STAGE: dict[str, tuple[str, ...]] = {
93
+ "gold_ready": (
94
+ "python -m seshat.cli validate --source-map "
95
+ "mappings/<table>/source-map.yaml # needs the db extra + a DSN; "
96
+ "without them, report the deferred state -- never fake a pass",
97
+ ),
98
+ "semantic_model_ready": ("python -m seshat.cli semantic-check --repo .",),
99
+ }
100
+
101
+ _STOP_POINT_BY_STAGE: dict[str, str] = {
102
+ "source_ready": (
103
+ "Stop once the read-only source profile and readiness-status.yaml are "
104
+ "recorded; mapping review is a human gate."
105
+ ),
106
+ "mapping_ready": (
107
+ "Stop at the mapping gate: source-map.yaml must be reviewed and "
108
+ "approved by a named human before any silver SQL."
109
+ ),
110
+ "silver_ready": (
111
+ "Stop after authoring the silver migration SQL; do not execute it and "
112
+ "do not begin gold work before Silver Ready passes."
113
+ ),
114
+ "gold_ready": (
115
+ "Stop after authoring the gold SQL and preparing live-validate "
116
+ "evidence; Gold Ready passes only on live validation."
117
+ ),
118
+ "semantic_model_ready": (
119
+ "Stop at the metric-contract gate: the metric owner approves the "
120
+ "contracts before any dashboard work."
121
+ ),
122
+ "dashboard_ready": (
123
+ "Stop at the design-review gate: governance approves the dashboard "
124
+ "design before publish preparation."
125
+ ),
126
+ "publish_ready": (
127
+ "Stop before any publish: assemble the handoff pack only; live "
128
+ "publish is the deferred F016 execution adapter."
129
+ ),
130
+ }
131
+
132
+ _TERMINAL_STOP_POINT = (
133
+ "All seven stages pass. Live publish stays with the deferred F016 "
134
+ "execution adapter; nothing further from this surface."
135
+ )
136
+
137
+ _FRESH_NEXT_ACTION = (
138
+ "No readiness evidence found under mappings/. Begin at Source Ready: "
139
+ "onboard one table with a read-only source profile and record "
140
+ "mappings/<table>/readiness-status.yaml before any warehouse or "
141
+ "dashboard work."
142
+ )
143
+
144
+
145
+ def _stage_index(stage: str | None) -> int:
146
+ """Spine position for ranking; terminal (``None``) sorts last."""
147
+ if stage is None:
148
+ return len(_STAGE_ORDER)
149
+ return _STAGE_ORDER.index(stage)
150
+
151
+
152
+ def _forbidden_scope(stage: str | None, outcome: str) -> list[str]:
153
+ """Every gate at or after the current stage is still closed; the
154
+ invariants hold always. Deterministic given (stage, outcome)."""
155
+ if outcome == "terminal_pass" or stage is None:
156
+ gates: list[str] = []
157
+ else:
158
+ current = _stage_index(stage)
159
+ gates = [
160
+ sentence
161
+ for gate_stage, sentence in _GATE_RULES
162
+ if _stage_index(gate_stage) >= current
163
+ ]
164
+ return gates + list(_ALWAYS_FORBIDDEN)
165
+
166
+
167
+ def _validation_commands(stage: str | None) -> list[str]:
168
+ commands = list(_BASE_VALIDATION_COMMANDS)
169
+ commands.extend(_VALIDATION_EXTRAS_BY_STAGE.get(stage or "", ()))
170
+ return commands
171
+
172
+
173
+ def _stop_point(response: dict[str, Any]) -> str:
174
+ outcome = response["outcome"]
175
+ stage = response["stage"]
176
+ if outcome == "terminal_pass":
177
+ return _TERMINAL_STOP_POINT
178
+ if outcome == "stop_blocked":
179
+ return (
180
+ "Stopped now: resolve or escalate the recorded blocking_reasons; "
181
+ "do not work around the block."
182
+ )
183
+ if outcome == "approval_required":
184
+ authority = response.get("required_authority") or "named human"
185
+ return (
186
+ f"Stopped now: a named-human approval ({authority}) for stage "
187
+ f"{stage!r} is required before any further stage work."
188
+ )
189
+ if outcome == "input_defect":
190
+ return (
191
+ "Stopped now: repair the malformed readiness-status.yaml before "
192
+ "any pipeline work."
193
+ )
194
+ return _STOP_POINT_BY_STAGE.get(stage or "", _STOP_POINT_BY_STAGE["source_ready"])
195
+
196
+
197
+ def _next_allowed_action(response: dict[str, Any]) -> str:
198
+ outcome = response["outcome"]
199
+ stage = response["stage"]
200
+ if outcome == "next_action":
201
+ return str(response.get("action_text") or "")
202
+ if outcome == "stop_blocked":
203
+ return (
204
+ f"STOP -- stage {stage!r} is blocked; resolve the recorded "
205
+ "blocking_reasons before any other pipeline work."
206
+ )
207
+ if outcome == "approval_required":
208
+ authority = response.get("required_authority") or "named human"
209
+ return (
210
+ f"STOP -- obtain the named-human approval ({authority}) for "
211
+ f"stage {stage!r}; never self-grant it."
212
+ )
213
+ if outcome == "terminal_pass":
214
+ return "No pipeline action: all seven readiness stages pass for this table."
215
+ return "Repair the readiness-status.yaml input defect before any pipeline work."
216
+
217
+
218
+ def _readiness_state(
219
+ response: dict[str, Any], entry: dict[str, Any] | None
220
+ ) -> str | None:
221
+ """The RECORDED four-status of the current stage, read from the same
222
+ committed projection -- never derived. ``input_defect`` has no honest
223
+ state, so it projects as ``None``."""
224
+ outcome = response["outcome"]
225
+ if outcome == "terminal_pass":
226
+ return "pass"
227
+ if outcome == "input_defect":
228
+ return None
229
+ stage = response["stage"]
230
+ if entry is not None and stage is not None:
231
+ block = entry.get("stages", {}).get(stage)
232
+ if isinstance(block, dict) and isinstance(block.get("status"), str):
233
+ return block["status"]
234
+ # No readiness file (or stage block unreadable): the conservative,
235
+ # non-fabricated state is the journey's start.
236
+ return "blocked" if outcome == "stop_blocked" else "not_started"
237
+
238
+
239
+ def _evidence(entry: dict[str, Any] | None) -> list[dict[str, Any]]:
240
+ """Every recorded stage, verbatim from the committed projection, in spine
241
+ order -- items are the readiness file's own evidence strings."""
242
+ if entry is None:
243
+ return []
244
+ stages = entry.get("stages", {})
245
+ return [
246
+ {
247
+ "stage": name,
248
+ "status": stages[name]["status"],
249
+ "items": list(stages[name]["evidence"]),
250
+ }
251
+ for name in _STAGE_ORDER
252
+ if name in stages
253
+ ]
254
+
255
+
256
+ def _summaries(
257
+ triples: list[tuple[dict[str, Any] | None, dict[str, Any], str]],
258
+ ) -> list[dict]:
259
+ return [
260
+ {
261
+ "table": response["table"],
262
+ "source_path": source_path,
263
+ "outcome": response["outcome"],
264
+ "stage": response["stage"],
265
+ }
266
+ for _entry, response, source_path in triples
267
+ ]
268
+
269
+
270
+ def _rank(response: dict[str, Any]) -> int:
271
+ """Focus ranking: a malformed file is the most urgent, then the earliest
272
+ incomplete stage; a fully-passed table sorts last."""
273
+ if response["outcome"] == "input_defect":
274
+ return -1
275
+ return _stage_index(response["stage"])
276
+
277
+
278
+ def _compose(
279
+ response: dict[str, Any],
280
+ entry: dict[str, Any] | None,
281
+ summaries: list[dict],
282
+ ) -> dict[str, Any]:
283
+ stage = response["stage"]
284
+ outcome = response["outcome"]
285
+ return {
286
+ "current_stage": stage,
287
+ "readiness_state": _readiness_state(response, entry),
288
+ "evidence": _evidence(entry),
289
+ "blocking_reasons": list(response.get("blocking_reasons", [])),
290
+ "next_allowed_action": _next_allowed_action(response),
291
+ "forbidden_scope": _forbidden_scope(stage, outcome),
292
+ "validation_commands": _validation_commands(stage),
293
+ "stop_point": _stop_point(response),
294
+ "table": response["table"],
295
+ "outcome": outcome,
296
+ "required_authority": response.get("required_authority"),
297
+ "caveats": list(response.get("caveats", [])),
298
+ "tables": summaries,
299
+ "read_only_proof": True,
300
+ }
301
+
302
+
303
+ def _fresh_repo_document() -> dict[str, Any]:
304
+ """No committed readiness evidence at all: the conservative,
305
+ evidence-first answer -- never a fabricated stage or state."""
306
+ return {
307
+ "current_stage": "source_ready",
308
+ "readiness_state": "not_started",
309
+ "evidence": [],
310
+ "blocking_reasons": [],
311
+ "next_allowed_action": _FRESH_NEXT_ACTION,
312
+ "forbidden_scope": _forbidden_scope("source_ready", "next_action"),
313
+ "validation_commands": _validation_commands("source_ready"),
314
+ "stop_point": _STOP_POINT_BY_STAGE["source_ready"],
315
+ "table": None,
316
+ "outcome": "next_action",
317
+ "required_authority": None,
318
+ "caveats": [],
319
+ "tables": [],
320
+ "read_only_proof": True,
321
+ }
322
+
323
+
324
+ def _dir_name(source_path: str) -> str:
325
+ """The ``mappings/<dir>/`` directory name -- the identity
326
+ ``build_run_next_response`` always resolves via its direct candidate path,
327
+ even when the file records no string ``table`` field."""
328
+ return source_path.rsplit("/", 2)[-2]
329
+
330
+
331
+ def _unprojected_status_paths(root: Path, entries: list[dict[str, Any]]) -> list[str]:
332
+ """Committed readiness-status files the best-effort projection SKIPPED
333
+ (unreadable / unparseable / non-mapping). They must still surface -- as
334
+ ``input_defect``, never as an absent table -- or a broken committed file
335
+ would silently read as a fresh journey."""
336
+ projected = {entry["source_path"] for entry in entries}
337
+ mappings_dir = root / "mappings"
338
+ if not mappings_dir.is_dir():
339
+ return []
340
+ return [
341
+ path.relative_to(root).as_posix()
342
+ for path in sorted(mappings_dir.glob("*/readiness-status.yaml"))
343
+ if path.relative_to(root).as_posix() not in projected
344
+ ]
345
+
346
+
347
+ def _all_triples(
348
+ root: Path, entries: list[dict[str, Any]]
349
+ ) -> list[tuple[dict[str, Any] | None, dict[str, Any], str]]:
350
+ """One ``(projection entry, run-next response, source path)`` triple per
351
+ committed readiness-status file, including files the projection skipped
352
+ (entry ``None``; their run-next outcome is ``input_defect``)."""
353
+ triples: list[tuple[dict[str, Any] | None, dict[str, Any], str]] = [
354
+ (
355
+ entry,
356
+ build_run_next_response(root, _dir_name(entry["source_path"])),
357
+ entry["source_path"],
358
+ )
359
+ for entry in entries
360
+ ]
361
+ for source_path in _unprojected_status_paths(root, entries):
362
+ triples.append(
363
+ (
364
+ None,
365
+ build_run_next_response(root, _dir_name(source_path)),
366
+ source_path,
367
+ )
368
+ )
369
+ return triples
370
+
371
+
372
+ def _resolved_source_path(root: Path, table: str) -> str | None:
373
+ """The repo-relative path of the readiness file run-next itself resolves
374
+ for ``table`` (its ``_find_status_data`` matches dir name / recorded
375
+ table / source_id -- reused, not re-derived)."""
376
+ from seshat.run_next import _find_status_data
377
+
378
+ status_path, _data, _error = _find_status_data(root, table)
379
+ if status_path is None:
380
+ return None
381
+ return status_path.relative_to(root).as_posix()
382
+
383
+
384
+ def _entry_by_source_path(
385
+ entries: list[dict[str, Any]], source_path: str | None
386
+ ) -> dict[str, Any] | None:
387
+ if source_path is None:
388
+ return None
389
+ return next((e for e in entries if e["source_path"] == source_path), None)
390
+
391
+
392
+ def _entry_by_name(
393
+ entries: list[dict[str, Any]], names: set[str | None]
394
+ ) -> dict[str, Any] | None:
395
+ return next(
396
+ (e for e in entries if {e.get("table"), _dir_name(e["source_path"])} & names),
397
+ None,
398
+ )
399
+
400
+
401
+ def _entry_matching(
402
+ root: Path,
403
+ entries: list[dict[str, Any]],
404
+ table: str,
405
+ response: dict[str, Any],
406
+ ) -> dict[str, Any] | None:
407
+ """Find the projection entry behind a --table response: authoritatively by
408
+ the source path of the file run-next resolved, else by name."""
409
+ by_path = _entry_by_source_path(entries, _resolved_source_path(root, table))
410
+ if by_path is not None:
411
+ return by_path
412
+ return _entry_by_name(entries, {response.get("table"), table})
413
+
414
+
415
+ def build_table_next_document(repo_root: Path | str, table: str) -> dict[str, Any]:
416
+ """Single-table next-action document WITHOUT the portfolio summaries.
417
+
418
+ Same composed shape as :func:`build_agent_next_document`, but it reads
419
+ only this table's readiness file (one run-next response) instead of
420
+ re-projecting every table -- O(1) file reads, which keeps portfolio-wide
421
+ consumers (the shared readiness projection, spec 120) linear instead of
422
+ quadratic. ``tables`` is empty and the entry-derived fields
423
+ (``readiness_state``/``evidence``) degrade conservatively; callers that
424
+ need those use the full document."""
425
+ return _compose(build_run_next_response(Path(repo_root), table), None, [])
426
+
427
+
428
+ def build_agent_next_document(
429
+ repo_root: Path | str, table: str | None = None
430
+ ) -> dict[str, Any]:
431
+ """Build the agent-facing next-action document for ``repo_root``.
432
+
433
+ With ``table``, the document focuses that table (missing file degrades to
434
+ the conservative Source Ready start, exactly as ``build_run_next_response``
435
+ does). Without it, the focus is the table with the most urgent run-next
436
+ outcome -- a malformed committed readiness file first, then the earliest
437
+ incomplete stage (ties broken by source path, so the answer is
438
+ deterministic); a repo with no readiness files at all produces the
439
+ conservative evidence-first document. Read-only in every path.
440
+ """
441
+ root = Path(repo_root)
442
+ projection = build_status_projection(root)
443
+ entries: list[dict[str, Any]] = projection["tables"]
444
+ triples = _all_triples(root, entries)
445
+
446
+ if table is not None:
447
+ response = build_run_next_response(root, table)
448
+ entry = _entry_matching(root, entries, table, response)
449
+ return _compose(response, entry, _summaries(triples))
450
+
451
+ if not triples:
452
+ return _fresh_repo_document()
453
+
454
+ focus_entry, focus_response, _ = min(
455
+ triples, key=lambda triple: (_rank(triple[1]), triple[2])
456
+ )
457
+ return _compose(focus_response, focus_entry, _summaries(triples))