multi-agent-platform 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 (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,381 @@
1
+ import uuid
2
+ from collections import Counter
3
+ from typing import Any
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from server.domain.models import (
9
+ Agent,
10
+ AgentRole,
11
+ ExperimentPhase,
12
+ Review,
13
+ ReviewItem,
14
+ TopicActionItem,
15
+ TopicActionItemStatus,
16
+ )
17
+ from server.domain.schemas import (
18
+ ExperimentComplete,
19
+ ExperimentLogCreate,
20
+ ExperimentResultDecision,
21
+ ReviewVerdict,
22
+ ReviewVerdictFile,
23
+ )
24
+ from server.domain.state_machine import validate_phase_transition
25
+ from server.services import audit_service, topic_service
26
+ from server.services.errors import ForbiddenError, StateTransitionError
27
+ from server.services.evidence_service import EVIDENCE_METADATA_KEYS, metadata_has_completion_evidence
28
+ from server.services.log_service import append_log
29
+ from server.services.phase_owner_resolver import owner_for
30
+ from server.services.project_service import get_experiment
31
+ from server.services.review_service import assert_approve_eligibility
32
+
33
+
34
+ def _sync_phase_owner(experiment) -> None:
35
+ """Mirror ``experiment.phase_owner`` to the resolver's table.
36
+
37
+ Called immediately after every ``experiment.phase = ...`` assignment
38
+ in this module so the two never drift. Kept as a thin wrapper so a
39
+ future override path (e.g. admin override, experiment.phase_owner
40
+ column hand-edit) can plug in here without touching every site.
41
+
42
+ Type-annotation uses a string forward reference (``Experiment``) so
43
+ this helper can live next to its callers without pulling the heavy
44
+ model imports into this module's top-level namespace.
45
+ """
46
+ experiment.phase_owner = owner_for(experiment.phase).value
47
+
48
+
49
+ def submit_for_review(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
50
+ experiment = get_experiment(db, experiment_id)
51
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
52
+ raise ForbiddenError("Only the creator can submit for review")
53
+ if experiment.current_plan_version < 1:
54
+ raise StateTransitionError("Experiment must have a plan before review")
55
+ validate_phase_transition(experiment.phase, ExperimentPhase.review)
56
+ experiment.phase = ExperimentPhase.review
57
+ _sync_phase_owner(experiment)
58
+ db.commit()
59
+
60
+
61
+ def approve_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
62
+ experiment = get_experiment(db, experiment_id)
63
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
64
+ raise ForbiddenError("Only the creator can approve the experiment")
65
+ assert_approve_eligibility(db, experiment)
66
+ validate_phase_transition(experiment.phase, ExperimentPhase.approved)
67
+ experiment.phase = ExperimentPhase.approved
68
+ _sync_phase_owner(experiment)
69
+ db.commit()
70
+
71
+
72
+ def withdraw_from_review(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
73
+ experiment = get_experiment(db, experiment_id)
74
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
75
+ raise ForbiddenError("Only the creator can withdraw from review")
76
+ validate_phase_transition(experiment.phase, ExperimentPhase.draft)
77
+ experiment.phase = ExperimentPhase.draft
78
+ _sync_phase_owner(experiment)
79
+ db.commit()
80
+
81
+
82
+ def cancel_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
83
+ experiment = get_experiment(db, experiment_id)
84
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
85
+ raise ForbiddenError("Only the creator can cancel the experiment")
86
+ validate_phase_transition(experiment.phase, ExperimentPhase.cancelled)
87
+ experiment.phase = ExperimentPhase.cancelled
88
+ _sync_phase_owner(experiment)
89
+ db.commit()
90
+
91
+
92
+ def start_experiment(db: Session, experiment_id: uuid.UUID, actor: Agent) -> None:
93
+ experiment = get_experiment(db, experiment_id)
94
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
95
+ raise ForbiddenError("Only the creator can start the experiment")
96
+ validate_phase_transition(experiment.phase, ExperimentPhase.running)
97
+ experiment.phase = ExperimentPhase.running
98
+ _sync_phase_owner(experiment)
99
+ db.commit()
100
+
101
+
102
+ def _ensure_result_reviewer(experiment_creator_id: uuid.UUID, actor: Agent) -> None:
103
+ if actor.role == AgentRole.admin:
104
+ return
105
+ if actor.id == experiment_creator_id:
106
+ raise ForbiddenError("Experiment result must be reviewed by another agent")
107
+
108
+
109
+ # I1(d): the host creator of the experiment is structurally forbidden from
110
+ # rejecting their own result — that intent is modelled by the per-item
111
+ # ``resolve-item --status rebutted`` flow during the review phase. Misusing
112
+ # ``reject-result`` for the wrong intent (e.g. trying to reject a single
113
+ # review item) gets a structured subcode instead of a generic 403 so the
114
+ # CLI / SDK can route the user to the right command.
115
+ REJECT_RESULT_MISUSE_HINT = (
116
+ "单 item 驳回请用 resolve-item --status rebutted (review 阶段); "
117
+ "整个实验驳回请让 reviewer / admin 调用 reject-result, "
118
+ "host creator 不可拒绝自己的 result。"
119
+ )
120
+
121
+
122
+ def raise_reject_result_misuse(
123
+ *,
124
+ actor_id: uuid.UUID,
125
+ experiment_id: uuid.UUID,
126
+ detail: str | None = None,
127
+ ) -> None:
128
+ """Emit a :class:`StateTransitionError` carrying the
129
+ ``REVIEW_REJECT_RESULT_MISUSE`` subcode.
130
+
131
+ Used when the host creator attempts to call ``reject-result`` (the wrong
132
+ command for their intent) or any caller tries to use ``reject-result``
133
+ as a stand-in for the per-item ``resolve-item --status rebutted`` flow.
134
+ """
135
+ message = detail or "reject-result 被错误使用: " + REJECT_RESULT_MISUSE_HINT
136
+ raise StateTransitionError(
137
+ message,
138
+ error_code="REVIEW_REJECT_RESULT_MISUSE",
139
+ hint=REJECT_RESULT_MISUSE_HINT,
140
+ retryable=False,
141
+ )
142
+
143
+
144
+ def _validate_and_summarize_verdict_file(
145
+ db: Session, experiment_id: uuid.UUID, verdict_file: ReviewVerdictFile
146
+ ) -> dict[str, Any]:
147
+ """Validate item_id.review_id ownership and compute R6 verdict breakdown.
148
+
149
+ The plan (b): ownership is keyed on review_id. Two crossing cases rejected:
150
+ - same reviewer across experiments (item_id from a closed experiment)
151
+ - different reviewer same experiment (item_id from another reviewer's
152
+ review on the same experiment)
153
+
154
+ Strict interpretation: each item_id's actual ``review_id`` must equal
155
+ ``verdict_file.review_id`` AND that review must belong to the current
156
+ experiment. The verdict_file's review_id must likewise belong to this
157
+ experiment. Anything else → 403.
158
+
159
+ Returns a metadata fragment to merge into ``experiment_logs.metadata_json``
160
+ with ``pre_schema_accept_result='false'`` + the verdict breakdown. Caller
161
+ is responsible for adding it to the log.
162
+ """
163
+ experiment_review_ids = set(
164
+ db.scalars(select(Review.id).where(Review.experiment_id == experiment_id)).all()
165
+ )
166
+ if verdict_file.review_id not in experiment_review_ids:
167
+ raise ForbiddenError(
168
+ f"verdict_file.review_id {verdict_file.review_id} does not belong "
169
+ f"to experiment {experiment_id}"
170
+ )
171
+ verdict_review_item_ids = set(
172
+ db.scalars(
173
+ select(ReviewItem.id).where(ReviewItem.review_id == verdict_file.review_id)
174
+ ).all()
175
+ )
176
+ for verdict in verdict_file.verdicts:
177
+ if verdict.item_id not in verdict_review_item_ids:
178
+ raise ForbiddenError(
179
+ f"verdict item_id {verdict.item_id} does not belong to "
180
+ f"verdict_file.review_id {verdict_file.review_id} on experiment "
181
+ f"{experiment_id}"
182
+ )
183
+ for invariant in verdict_file.invariants:
184
+ if invariant.item_id not in verdict_review_item_ids:
185
+ raise ForbiddenError(
186
+ f"invariant item_id {invariant.item_id} does not belong to "
187
+ f"verdict_file.review_id {verdict_file.review_id} on experiment "
188
+ f"{experiment_id}"
189
+ )
190
+ breakdown = Counter(v.verdict.value for v in verdict_file.verdicts)
191
+ return {
192
+ "pre_schema_accept_result": "false",
193
+ "verdict_breakdown": {
194
+ "passed": breakdown.get(ReviewVerdict.passed.value, 0),
195
+ "failed": breakdown.get(ReviewVerdict.failed.value, 0),
196
+ "waived": breakdown.get(ReviewVerdict.waived.value, 0),
197
+ },
198
+ "verdict_file": verdict_file.model_dump(mode="json"),
199
+ }
200
+
201
+
202
+ def _legacy_accept_result_metadata(payload_metadata: dict[str, Any] | None) -> dict[str, Any]:
203
+ """Attach the legacy / pre-schema marker when caller omits verdict_file."""
204
+ fragment = {"pre_schema_accept_result": "true", "verdict_breakdown": None}
205
+ if payload_metadata:
206
+ return {**payload_metadata, **fragment}
207
+ return fragment
208
+
209
+
210
+ def complete_experiment(
211
+ db: Session,
212
+ experiment_id: uuid.UUID,
213
+ actor: Agent,
214
+ payload: ExperimentComplete,
215
+ ) -> None:
216
+ experiment = get_experiment(db, experiment_id)
217
+ if experiment.creator_agent_id != actor.id and actor.role != AgentRole.admin:
218
+ raise ForbiddenError("Only the creator can complete the experiment")
219
+ validate_phase_transition(experiment.phase, ExperimentPhase.result_review)
220
+ if not metadata_has_completion_evidence(payload.metadata):
221
+ keys = ", ".join(sorted(EVIDENCE_METADATA_KEYS))
222
+ raise StateTransitionError(
223
+ "Experiment complete requires deployment/test evidence metadata "
224
+ f"(accepted keys include: {keys}; or evidence/allow_missing_evidence)."
225
+ )
226
+ append_log(
227
+ db,
228
+ experiment_id,
229
+ actor,
230
+ ExperimentLogCreate(
231
+ summary=payload.summary,
232
+ content_md=payload.content_md,
233
+ metadata=payload.metadata,
234
+ ),
235
+ )
236
+ experiment.phase = ExperimentPhase.result_review
237
+ _sync_phase_owner(experiment)
238
+ db.commit()
239
+
240
+
241
+ def accept_result(
242
+ db: Session,
243
+ experiment_id: uuid.UUID,
244
+ actor: Agent,
245
+ payload: ExperimentResultDecision,
246
+ ) -> None:
247
+ experiment = get_experiment(db, experiment_id)
248
+ _ensure_result_reviewer(experiment.creator_agent_id, actor)
249
+ validate_phase_transition(experiment.phase, ExperimentPhase.done)
250
+
251
+ # === A2 cascade: 同事务把关联的 open action_item 自动 done ===
252
+ open_items = db.scalars(
253
+ select(TopicActionItem).where(
254
+ TopicActionItem.linked_experiment_id == experiment_id,
255
+ TopicActionItem.status == TopicActionItemStatus.open,
256
+ )
257
+ ).all()
258
+ cascaded: list[dict[str, Any]] = []
259
+ for item in open_items:
260
+ cascaded.append(
261
+ topic_service._complete_action_item_no_commit(
262
+ db,
263
+ item,
264
+ triggered_by=f"experiment.completed:{experiment_id}",
265
+ )
266
+ )
267
+
268
+ if payload.verdict_file is not None:
269
+ verdict_fragment = _validate_and_summarize_verdict_file(
270
+ db, experiment_id, payload.verdict_file
271
+ )
272
+ merged_metadata = (
273
+ {**(payload.metadata or {}), **verdict_fragment}
274
+ if payload.metadata
275
+ else verdict_fragment
276
+ )
277
+ else:
278
+ merged_metadata = _legacy_accept_result_metadata(payload.metadata)
279
+
280
+ append_log(
281
+ db,
282
+ experiment_id,
283
+ actor,
284
+ ExperimentLogCreate(
285
+ summary=payload.summary,
286
+ content_md=payload.content_md,
287
+ metadata=merged_metadata,
288
+ ),
289
+ )
290
+ experiment.phase = ExperimentPhase.done
291
+ _sync_phase_owner(experiment)
292
+
293
+ audit_service.log_no_commit(
294
+ db,
295
+ action="experiment.completed",
296
+ target_type="experiment",
297
+ target_id=experiment_id,
298
+ agent_id=actor.id,
299
+ project_id=experiment.project_id,
300
+ summary=f"实验完成「{experiment.title}」",
301
+ payload={
302
+ "experiment_id": str(experiment_id),
303
+ "cascaded_action_items": cascaded,
304
+ "pre_schema_accept_result": "true"
305
+ if payload.verdict_file is None
306
+ else "false",
307
+ },
308
+ )
309
+
310
+ db.commit()
311
+
312
+
313
+ def reject_result(
314
+ db: Session,
315
+ experiment_id: uuid.UUID,
316
+ actor: Agent,
317
+ payload: ExperimentResultDecision,
318
+ ) -> None:
319
+ experiment = get_experiment(db, experiment_id)
320
+ # I1(d): the host creator of the experiment is structurally forbidden
321
+ # from rejecting their own result. That intent lives on the per-item
322
+ # ``resolve-item --status rebutted`` path during the review phase, so
323
+ # misuse here gets a structured subcode instead of a generic 403.
324
+ if (
325
+ actor.role != AgentRole.admin
326
+ and actor.id == experiment.creator_agent_id
327
+ ):
328
+ raise_reject_result_misuse(
329
+ actor_id=actor.id,
330
+ experiment_id=experiment_id,
331
+ detail=(
332
+ "Experiment creator cannot reject their own result. "
333
+ "单 item 驳回请用 resolve-item --status rebutted, "
334
+ "整个实验驳回需 reviewer / admin。"
335
+ ),
336
+ )
337
+ # Non-creator non-admin callers (reviewer) still pass through the legacy
338
+ # ``_ensure_result_reviewer`` guard. Admin bypass is intentional for
339
+ # back-compat (admins can substitute-reject when a reviewer is absent).
340
+ _ensure_result_reviewer(experiment.creator_agent_id, actor)
341
+ validate_phase_transition(experiment.phase, ExperimentPhase.running)
342
+ if payload.verdict_file is not None:
343
+ verdict_fragment = _validate_and_summarize_verdict_file(
344
+ db, experiment_id, payload.verdict_file
345
+ )
346
+ merged_metadata = (
347
+ {**(payload.metadata or {}), **verdict_fragment}
348
+ if payload.metadata
349
+ else verdict_fragment
350
+ )
351
+ else:
352
+ merged_metadata = _legacy_accept_result_metadata(payload.metadata)
353
+ append_log(
354
+ db,
355
+ experiment_id,
356
+ actor,
357
+ ExperimentLogCreate(
358
+ summary=payload.summary,
359
+ content_md=payload.content_md,
360
+ metadata=merged_metadata,
361
+ ),
362
+ )
363
+ # I1(e): experiment-level reject-result also writes a
364
+ # ``review_item.mutation`` audit row at the experiment scope so admins
365
+ # can filter the audit log by ``--experiment <id>`` and see the
366
+ # reject-result alongside per-item mutations.
367
+ audit_service.log_review_item_mutation_no_commit(
368
+ db,
369
+ item=None,
370
+ experiment_id=experiment_id,
371
+ actor_id=actor.id,
372
+ project_id=experiment.project_id,
373
+ action="reject_result",
374
+ before_state="result_review",
375
+ after_state="running",
376
+ reason=payload.summary,
377
+ target_id=experiment_id,
378
+ )
379
+ experiment.phase = ExperimentPhase.running
380
+ _sync_phase_owner(experiment)
381
+ db.commit()
@@ -0,0 +1,235 @@
1
+ """a764abf6 I1.(a) — plan frontmatter marker validator.
2
+
3
+ Provides two entry points:
4
+
5
+ * :func:`validate_plan_frontmatter` — soft validator returning a
6
+ :class:`PlanMarkerValidationResult`. ``valid`` is always True. Used by
7
+ the CLI ``plan validate`` command (plan (c)) and by read-only
8
+ diagnostics where failure must not block.
9
+
10
+ * :func:`assert_plan_frontmatter_ok` — hard validator raising
11
+ :class:`StateTransitionError` with ``error_code=STATE_MACHINE_PLAN_MARKER_MISSING``
12
+ + a ``hint`` listing the missing fields when validation fails. Wired
13
+ into the ``plan_revise`` and ``create_experiment`` paths per plan (a)
14
+ acceptance so missing required fields block the save with a 422.
15
+
16
+ The 4 required frontmatter fields (markdown fenced ``---`` YAML block):
17
+
18
+ * ``title`` — non-empty string
19
+ * ``acceptance`` — non-empty list or non-empty string
20
+ * ``evidence_keys`` — non-empty list or non-empty string
21
+ * ``dependencies`` — non-empty list or non-empty string
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from typing import Literal
29
+
30
+ import yaml
31
+
32
+ from server.services.errors import StateTransitionError
33
+
34
+ PlanMarkerWarningCode = Literal[
35
+ "PLAN_MARKER_MISSING_FIELD",
36
+ "PLAN_MARKER_EMPTY_LIST",
37
+ "PLAN_MARKER_PARSE_ERROR",
38
+ "PLAN_MARKER_FRONT_MATTER_MISSING",
39
+ ]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PlanMarkerWarning:
44
+ code: PlanMarkerWarningCode
45
+ field: str | None = None
46
+ detail: str | None = None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PlanMarkerValidationResult:
51
+ warnings: list[PlanMarkerWarning] = field(default_factory=list)
52
+ fields_present: tuple[str, ...] = ()
53
+ frontmatter: dict[str, object] = field(default_factory=dict)
54
+
55
+ @property
56
+ def valid(self) -> bool:
57
+ return True
58
+
59
+
60
+ REQUIRED_PLAN_FIELDS: tuple[str, ...] = (
61
+ "title",
62
+ "acceptance",
63
+ "evidence_keys",
64
+ "dependencies",
65
+ )
66
+
67
+ _FRONT_MATTER_RE = re.compile(
68
+ r"\A\s*---\s*\n(?P<body>.*?)\n---\s*(?:\n|\Z)",
69
+ re.DOTALL,
70
+ )
71
+
72
+
73
+ def _extract_frontmatter(content_md: str | None) -> tuple[str | None, dict[str, object]]:
74
+ """Return ``(raw_yaml_or_None, parsed_dict)``.
75
+
76
+ * ``(None, {})`` — no frontmatter block detected.
77
+ * ``(raw, {})`` — frontmatter block present but YAML failed to parse
78
+ to a mapping (treat as parse error).
79
+ * ``(raw, dict)`` — frontmatter parsed.
80
+ """
81
+ if not content_md:
82
+ return None, {}
83
+ match = _FRONT_MATTER_RE.match(content_md)
84
+ if match is None:
85
+ return None, {}
86
+ raw = match.group("body")
87
+ try:
88
+ parsed = yaml.safe_load(raw) or {}
89
+ except yaml.YAMLError:
90
+ return raw, {}
91
+ if not isinstance(parsed, dict):
92
+ return raw, {}
93
+ return raw, dict(parsed)
94
+
95
+
96
+ def _is_nonempty_string(value: object) -> bool:
97
+ return isinstance(value, str) and value.strip() != ""
98
+
99
+
100
+ def _is_nonempty_list(value: object) -> bool:
101
+ return isinstance(value, list) and len(value) > 0
102
+
103
+
104
+ def _collect_warnings(parsed: dict[str, object] | None) -> tuple[
105
+ list[PlanMarkerWarning], tuple[str, ...]
106
+ ]:
107
+ """Compute the warning list for a parsed (or None) frontmatter dict.
108
+
109
+ Returns ``(warnings, fields_present)``.
110
+ """
111
+ if parsed is None:
112
+ warnings: list[PlanMarkerWarning] = [
113
+ PlanMarkerWarning(code="PLAN_MARKER_FRONT_MATTER_MISSING")
114
+ ]
115
+ warnings.extend(
116
+ PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
117
+ for name in REQUIRED_PLAN_FIELDS
118
+ )
119
+ return warnings, ()
120
+
121
+ if not parsed:
122
+ warnings = [PlanMarkerWarning(code="PLAN_MARKER_PARSE_ERROR")]
123
+ warnings.extend(
124
+ PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
125
+ for name in REQUIRED_PLAN_FIELDS
126
+ )
127
+ return warnings, ()
128
+
129
+ warnings = []
130
+ fields_present: list[str] = []
131
+ for name in REQUIRED_PLAN_FIELDS:
132
+ if name not in parsed or parsed[name] is None:
133
+ warnings.append(
134
+ PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
135
+ )
136
+ continue
137
+ value = parsed[name]
138
+ if name == "title":
139
+ if _is_nonempty_string(value):
140
+ fields_present.append(name)
141
+ else:
142
+ warnings.append(
143
+ PlanMarkerWarning(code="PLAN_MARKER_MISSING_FIELD", field=name)
144
+ )
145
+ else:
146
+ if _is_nonempty_list(value) or _is_nonempty_string(value):
147
+ fields_present.append(name)
148
+ else:
149
+ warnings.append(
150
+ PlanMarkerWarning(
151
+ code="PLAN_MARKER_EMPTY_LIST", field=name
152
+ )
153
+ )
154
+ return warnings, tuple(fields_present)
155
+
156
+
157
+ def validate_plan_frontmatter(
158
+ content_md: str | None,
159
+ ) -> PlanMarkerValidationResult:
160
+ """Soft validator — never raises. Returns warnings and the fields
161
+ that were detected as present.
162
+ """
163
+ raw, parsed = _extract_frontmatter(content_md)
164
+ if raw is None:
165
+ warnings, fields_present = _collect_warnings(None)
166
+ return PlanMarkerValidationResult(
167
+ warnings=warnings, fields_present=fields_present, frontmatter={}
168
+ )
169
+ if not parsed:
170
+ warnings, fields_present = _collect_warnings({})
171
+ return PlanMarkerValidationResult(
172
+ warnings=warnings, fields_present=fields_present, frontmatter={}
173
+ )
174
+ warnings, fields_present = _collect_warnings(parsed)
175
+ return PlanMarkerValidationResult(
176
+ warnings=warnings,
177
+ fields_present=fields_present,
178
+ frontmatter=parsed,
179
+ )
180
+
181
+
182
+ def assert_plan_frontmatter_ok(content_md: str | None) -> PlanMarkerValidationResult:
183
+ """Hard validator — raises ``StateTransitionError`` with
184
+ ``error_code=STATE_MACHINE_PLAN_MARKER_MISSING`` when required
185
+ fields are missing or malformed. Returns the (always valid) result
186
+ on success.
187
+
188
+ Wired into ``plan_revise`` and ``create_experiment`` per plan (a)
189
+ acceptance so a plan without frontmatter or required fields is
190
+ refused with HTTP 422 + structured error envelope.
191
+ """
192
+ raw, parsed = _extract_frontmatter(content_md)
193
+ if raw is None:
194
+ raise StateTransitionError(
195
+ "Plan frontmatter is missing",
196
+ error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
197
+ hint=(
198
+ "Plan must begin with a YAML frontmatter block containing "
199
+ f"{', '.join(REQUIRED_PLAN_FIELDS)}"
200
+ ),
201
+ )
202
+ if not parsed:
203
+ raise StateTransitionError(
204
+ "Plan frontmatter could not be parsed as a YAML mapping",
205
+ error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
206
+ hint=(
207
+ "Frontmatter must be a YAML mapping with required fields: "
208
+ f"{', '.join(REQUIRED_PLAN_FIELDS)}"
209
+ ),
210
+ )
211
+
212
+ warnings, fields_present = _collect_warnings(parsed)
213
+ if warnings:
214
+ missing = sorted(
215
+ w.field
216
+ for w in warnings
217
+ if w.code in ("PLAN_MARKER_MISSING_FIELD", "PLAN_MARKER_EMPTY_LIST")
218
+ and w.field
219
+ )
220
+ detail = ", ".join(missing) if missing else "see warnings"
221
+ raise StateTransitionError(
222
+ f"Plan frontmatter is missing required fields: {detail}",
223
+ error_code="STATE_MACHINE_PLAN_MARKER_MISSING",
224
+ hint=(
225
+ "Required frontmatter fields: "
226
+ f"{', '.join(REQUIRED_PLAN_FIELDS)} "
227
+ f"(missing: {detail})"
228
+ ),
229
+ )
230
+
231
+ return PlanMarkerValidationResult(
232
+ warnings=[],
233
+ fields_present=fields_present,
234
+ frontmatter=parsed,
235
+ )