context-rail 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 (151) hide show
  1. context_rail/__init__.py +3 -0
  2. context_rail/__main__.py +4 -0
  3. context_rail/autonomy/__init__.py +85 -0
  4. context_rail/autonomy/_contracts.py +487 -0
  5. context_rail/autonomy/_drift.py +173 -0
  6. context_rail/autonomy/_evidence_harvest.py +210 -0
  7. context_rail/autonomy/_intent_match.py +145 -0
  8. context_rail/autonomy/_pdca.py +783 -0
  9. context_rail/autonomy/_rails.py +909 -0
  10. context_rail/autonomy/_runs.py +947 -0
  11. context_rail/autonomy/_runtime.py +194 -0
  12. context_rail/autonomy.py +6 -0
  13. context_rail/brainstorm/__init__.py +156 -0
  14. context_rail/brainstorm/_phase_drafts.py +278 -0
  15. context_rail/brainstorm/_plan_drafts.py +491 -0
  16. context_rail/brainstorm/drafts.py +34 -0
  17. context_rail/brainstorm_artifacts.py +160 -0
  18. context_rail/brainstorm_conductor.py +440 -0
  19. context_rail/brainstorm_core.py +459 -0
  20. context_rail/brainstorm_synthesis.py +40 -0
  21. context_rail/cli.py +661 -0
  22. context_rail/context_pack.py +314 -0
  23. context_rail/db.py +432 -0
  24. context_rail/db_schema/__init__.py +19 -0
  25. context_rail/db_schema/_gates.py +78 -0
  26. context_rail/db_schema/_schema.py +763 -0
  27. context_rail/db_schema.py +2 -0
  28. context_rail/focus.py +306 -0
  29. context_rail/gates.py +117 -0
  30. context_rail/rules.py +201 -0
  31. context_rail/server/__init__.py +289 -0
  32. context_rail/server/_imports.py +165 -0
  33. context_rail/server/_tool_surface.py +60 -0
  34. context_rail/server/tools_artifacts.py +154 -0
  35. context_rail/server/tools_autonomy.py +122 -0
  36. context_rail/server/tools_brainstorm.py +192 -0
  37. context_rail/server/tools_categories.py +103 -0
  38. context_rail/server/tools_configure.py +69 -0
  39. context_rail/server/tools_context.py +1212 -0
  40. context_rail/server/tools_decisions.py +142 -0
  41. context_rail/server/tools_find.py +201 -0
  42. context_rail/server/tools_focus.py +313 -0
  43. context_rail/server/tools_gates.py +194 -0
  44. context_rail/server/tools_intent.py +67 -0
  45. context_rail/server/tools_lifecycle.py +194 -0
  46. context_rail/server/tools_notes.py +333 -0
  47. context_rail/server/tools_orient.py +421 -0
  48. context_rail/server/tools_phase_brainstorm.py +184 -0
  49. context_rail/server/tools_plan_review.py +520 -0
  50. context_rail/server/tools_planning.py +463 -0
  51. context_rail/server/tools_projects.py +492 -0
  52. context_rail/server/tools_retro.py +243 -0
  53. context_rail/server/tools_risks.py +107 -0
  54. context_rail/server/tools_roadmap.py +434 -0
  55. context_rail/server/tools_rules.py +204 -0
  56. context_rail/server/tools_runtime.py +25 -0
  57. context_rail/server/tools_tasks.py +906 -0
  58. context_rail/server.py +8 -0
  59. context_rail/web.py +350 -0
  60. context_rail/web_api.py +74 -0
  61. context_rail/web_autonomy_api.py +109 -0
  62. context_rail/web_core.py +59 -0
  63. context_rail/web_dist/assets/AutonomyRailPanel-CbhEF78e.js +16 -0
  64. context_rail/web_dist/assets/Badge-DLfxmmCr.js +1 -0
  65. context_rail/web_dist/assets/BrainstormTemplate-CHDOfvGo.js +1 -0
  66. context_rail/web_dist/assets/Button-B1Pa9rKn.js +1 -0
  67. context_rail/web_dist/assets/CalendarTemplate-DeIunyja.js +1 -0
  68. context_rail/web_dist/assets/DashboardTemplate-Car4j8De.js +1 -0
  69. context_rail/web_dist/assets/DecisionsTemplate-4MnmyHue.js +1 -0
  70. context_rail/web_dist/assets/DepsArtifactsTemplate-D-5DrBVK.js +1 -0
  71. context_rail/web_dist/assets/Divider-D4ZEQqYw.js +1 -0
  72. context_rail/web_dist/assets/Dot-DXSDh1s8.js +1 -0
  73. context_rail/web_dist/assets/EntityDetailPanel-Be5U9SXA.js +1 -0
  74. context_rail/web_dist/assets/ExecuteTemplate-BYJIQwUU.js +1 -0
  75. context_rail/web_dist/assets/KanbanBoardTemplate-t-ZciXC6.js +1 -0
  76. context_rail/web_dist/assets/PageHeader-R2cGDHn_.js +1 -0
  77. context_rail/web_dist/assets/ProjectsTemplate-CNe-Wyat.js +1 -0
  78. context_rail/web_dist/assets/ReviewTemplate-BzBmmZ8i.js +4 -0
  79. context_rail/web_dist/assets/RisksTemplate-DsjhCA0v.js +1 -0
  80. context_rail/web_dist/assets/SectionCard-CaEXeRuy.js +1 -0
  81. context_rail/web_dist/assets/SettingsTemplate-K2LGnXE6.js +1 -0
  82. context_rail/web_dist/assets/Sidebar-Dpbs2xk9.js +146 -0
  83. context_rail/web_dist/assets/StatusPill-DjACmQdp.js +1 -0
  84. context_rail/web_dist/assets/StructureTemplate-tHzD7-8T.js +1 -0
  85. context_rail/web_dist/assets/TaskDetailPanel-Bn7EbFIK.js +1 -0
  86. context_rail/web_dist/assets/Text-BIsiCk07.js +1 -0
  87. context_rail/web_dist/assets/TimelineTemplate-CZ4ptEB1.js +1 -0
  88. context_rail/web_dist/assets/TrashTemplate-D5Hzlygu.js +1 -0
  89. context_rail/web_dist/assets/calendar-CL12sWTL.js +1 -0
  90. context_rail/web_dist/assets/charts-BCwg4OMJ.js +67 -0
  91. context_rail/web_dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  92. context_rail/web_dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  93. context_rail/web_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  94. context_rail/web_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  95. context_rail/web_dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  96. context_rail/web_dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  97. context_rail/web_dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  98. context_rail/web_dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  99. context_rail/web_dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  100. context_rail/web_dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  101. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  102. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  103. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  104. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  105. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  106. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  107. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  108. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  109. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  110. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  111. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  112. context_rail/web_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  113. context_rail/web_dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  114. context_rail/web_dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  115. context_rail/web_dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  116. context_rail/web_dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  117. context_rail/web_dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  118. context_rail/web_dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  119. context_rail/web_dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  120. context_rail/web_dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  121. context_rail/web_dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  122. context_rail/web_dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  123. context_rail/web_dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  124. context_rail/web_dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  125. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  126. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  127. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  128. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  129. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  130. context_rail/web_dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  131. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  132. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  133. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  134. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  135. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  136. context_rail/web_dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  137. context_rail/web_dist/assets/index-BWbvE675.css +1 -0
  138. context_rail/web_dist/assets/index-CbFjhbCx.js +2 -0
  139. context_rail/web_dist/assets/react-RRCjlvKK.js +67 -0
  140. context_rail/web_dist/assets/sortable.esm-BNXrI9dD.js +5 -0
  141. context_rail/web_dist/index.html +14 -0
  142. context_rail/web_entities.py +471 -0
  143. context_rail/web_flow.py +344 -0
  144. context_rail/web_focus_api.py +265 -0
  145. context_rail/web_projects.py +372 -0
  146. context_rail-0.1.0.dist-info/METADATA +437 -0
  147. context_rail-0.1.0.dist-info/RECORD +151 -0
  148. context_rail-0.1.0.dist-info/WHEEL +5 -0
  149. context_rail-0.1.0.dist-info/entry_points.txt +2 -0
  150. context_rail-0.1.0.dist-info/licenses/LICENSE +21 -0
  151. context_rail-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Context Rail — agentic task-management that keeps your project on rails."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Allow running as python -m context_rail."""
2
+ from .cli import main
3
+
4
+ main()
@@ -0,0 +1,85 @@
1
+ """Context Rail autonomy subpackage — contracts, rail checks, PDCA cycles, and run orchestration."""
2
+
3
+ from ._runtime import (
4
+ Blocker,
5
+ BlockerLayer,
6
+ BlockerSeverity,
7
+ RuntimeCheckReport,
8
+ runtime_check,
9
+ )
10
+
11
+ from ._contracts import (
12
+ CONTRACT_FIELDS,
13
+ _contract_digest,
14
+ _json_dumps,
15
+ _json_load,
16
+ _project_exists,
17
+ _row_contract,
18
+ _row_cycle,
19
+ _snapshot_from_draft,
20
+ _task_row,
21
+ active_plan_contract,
22
+ apply_contract_defaults,
23
+ apply_contract_edits,
24
+ contract_review_lines,
25
+ create_plan_contract,
26
+ default_autonomy_policy,
27
+ default_drift_rules,
28
+ default_evidence_rules,
29
+ default_quality_contract,
30
+ default_rail_check_policy,
31
+ default_stop_rules,
32
+ )
33
+ from ._rails import (
34
+ _completed_task_count,
35
+ _create_default_rail_policies,
36
+ _pending_due_checks,
37
+ _policy_row,
38
+ _rail_cursor_snapshot,
39
+ _record_due_rail_checks,
40
+ build_rail_check_packet,
41
+ collect_rail_context,
42
+ create_agent_rail_policy,
43
+ create_default_rail_policies,
44
+ detect_due_rail_checks,
45
+ evaluate_rail_check,
46
+ list_rail_policies,
47
+ record_rail_check,
48
+ resolve_rail_policies,
49
+ )
50
+ from ._drift import (
51
+ compute_plan_drift,
52
+ detect_decision_conflicts,
53
+ )
54
+ from ._pdca import (
55
+ _active_run_for_task,
56
+ _cycle_evidence,
57
+ _cycle_for_task,
58
+ attach_activity_to_active_cycle,
59
+ check_cycle,
60
+ handle_task_blocked,
61
+ handle_task_done,
62
+ precheck_task_done,
63
+ record_do,
64
+ record_evidence,
65
+ )
66
+ from ._runs import (
67
+ _active_cycle_for_task,
68
+ _parse_sqlite_datetime,
69
+ _row_run,
70
+ _select_next_task,
71
+ active_autonomy_run,
72
+ autonomy_gate,
73
+ autonomy_next,
74
+ autonomy_record,
75
+ autonomy_status,
76
+ build_execution_packet,
77
+ ensure_pdca_cycle_for_task,
78
+ execution_packet_for_task,
79
+ latest_autonomy_run,
80
+ lease_limit_report,
81
+ phase_gate_report,
82
+ resume_autonomy_run,
83
+ start_autonomy_run,
84
+ stop_run_for_user,
85
+ )
@@ -0,0 +1,487 @@
1
+ """Plan contract defaults, editing, persistence, and snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import sqlite3
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..db import get_db, init_db, load_state, log_activity, save_state, transaction
14
+
15
+
16
+ def _create_default_rail_policies(conn: sqlite3.Connection, project_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]:
17
+ from ._rails import _create_default_rail_policies as _impl
18
+ return _impl(conn, project_id, contract)
19
+
20
+
21
+ CONTRACT_FIELDS = {
22
+ "quality_contract",
23
+ "autonomy_policy",
24
+ "rail_check_policy",
25
+ "stop_rules",
26
+ "drift_rules",
27
+ "evidence_rules",
28
+ }
29
+
30
+
31
+ def default_quality_contract() -> dict[str, Any]:
32
+ return {
33
+ "good_feels_like": [
34
+ "Goal-first, not task-first",
35
+ "Dense but readable",
36
+ "Clear next action, blockers, and review state",
37
+ ],
38
+ "reference_examples": [],
39
+ "non_negotiables": [
40
+ "No hidden approval state",
41
+ "No task-by-task user babysitting",
42
+ "User-visible gates for meaningful direction changes",
43
+ ],
44
+ "acceptance_evidence": [
45
+ "tests_pass_or_gap_recorded",
46
+ "plan_alignment_checked",
47
+ ],
48
+ "quality_floor": [
49
+ "No broken primary flow",
50
+ "No invisible next action",
51
+ "No unchecked plan drift",
52
+ ],
53
+ }
54
+
55
+
56
+ def default_autonomy_policy() -> dict[str, Any]:
57
+ return {
58
+ "codex_can_decide": [
59
+ "Implementation details inside approved scope",
60
+ "Small bug fixes",
61
+ "Documentation and cleanup",
62
+ "Tests and verification steps",
63
+ ],
64
+ "codex_must_stop_for": [
65
+ "Product direction change",
66
+ "Major UX direction change",
67
+ "Architecture change",
68
+ "Scope expansion",
69
+ "Missing required quality evidence",
70
+ "Conflicting decisions",
71
+ ],
72
+ "max_cycles_without_user": 10,
73
+ "phase_gate_required": True,
74
+ "lease": {
75
+ "scope": "phase",
76
+ "max_cycles": 10,
77
+ "max_minutes": 120,
78
+ "visible_gate": "phase",
79
+ },
80
+ }
81
+
82
+
83
+ def default_rail_check_policy() -> dict[str, Any]:
84
+ return {
85
+ "default_every_completed_tasks": 5,
86
+ "also_check_on": [
87
+ "path_complete",
88
+ "goal_progress",
89
+ "goal_complete",
90
+ "before_phase_change",
91
+ "agent_lane_violation",
92
+ "decision_changed",
93
+ "evidence_missing",
94
+ "dependency_risk",
95
+ "scope_drift",
96
+ "quality_drift",
97
+ ],
98
+ "user_defined_paths": [],
99
+ }
100
+
101
+
102
+ def default_stop_rules() -> list[dict[str, Any]]:
103
+ return [
104
+ {
105
+ "id": "scope_expansion",
106
+ "severity": "hard",
107
+ "condition": "new_task_outside_plan",
108
+ "message": "New work is outside the approved plan contract.",
109
+ "required_action": "user_gate",
110
+ },
111
+ {
112
+ "id": "missing_required_evidence",
113
+ "severity": "hard",
114
+ "condition": "required_evidence_missing",
115
+ "message": "Required quality evidence is missing.",
116
+ "required_action": "collect_evidence_or_user_gate",
117
+ },
118
+ ]
119
+
120
+
121
+ def default_drift_rules() -> list[dict[str, Any]]:
122
+ return [
123
+ {"id": "unplanned_inside_phase", "score": 1, "condition": "unplanned_task_inside_current_phase"},
124
+ {"id": "unplanned_outside_phase", "score": 3, "condition": "unplanned_task_outside_current_phase"},
125
+ {"id": "direction_changed", "score": 5, "condition": "product_or_architecture_direction_changed"},
126
+ {"id": "non_negotiable_violated", "score": 6, "condition": "quality_contract_non_negotiable_violated"},
127
+ ]
128
+
129
+
130
+ def default_evidence_rules() -> list[dict[str, Any]]:
131
+ return [
132
+ {"id": "plan_alignment", "scope": "cycle", "required": True, "kind": "manual_check",
133
+ "hint": "summary must reference a verifiable artifact (path or URL)"},
134
+ {"id": "tests_or_reason", "scope": "phase", "required": True, "kind": "test",
135
+ "hint": "evidence must include metadata.falsifier (>=15 chars)"},
136
+ {"id": "phase_report", "scope": "phase", "required": True, "kind": "manual_check",
137
+ "hint": "summary must reference a verifiable artifact"},
138
+ ]
139
+
140
+
141
+ def apply_contract_defaults(draft: dict[str, Any]) -> dict[str, Any]:
142
+ """Ensure a draft contains the user-reviewable autonomy contract fields."""
143
+ draft.setdefault("quality_contract", default_quality_contract())
144
+ draft.setdefault("autonomy_policy", default_autonomy_policy())
145
+ draft.setdefault("rail_check_policy", default_rail_check_policy())
146
+ draft.setdefault("stop_rules", default_stop_rules())
147
+ draft.setdefault("drift_rules", default_drift_rules())
148
+ draft.setdefault("evidence_rules", default_evidence_rules())
149
+ return draft
150
+
151
+
152
+ def apply_contract_edits(draft: dict[str, Any], edits: dict[str, Any]) -> dict[str, Any]:
153
+ """Apply review-time edits to contract fields."""
154
+ for field in CONTRACT_FIELDS:
155
+ if field in edits:
156
+ draft[field] = edits[field]
157
+ return apply_contract_defaults(draft)
158
+
159
+
160
+ def contract_review_lines(draft: dict[str, Any]) -> list[str]:
161
+ """Return compact human-readable lines for contract fields in plan review."""
162
+ draft = apply_contract_defaults(draft)
163
+ quality = draft.get("quality_contract") or {}
164
+ autonomy = draft.get("autonomy_policy") or {}
165
+ rail = draft.get("rail_check_policy") or {}
166
+ lease = autonomy.get("lease") or {}
167
+ return [
168
+ "",
169
+ "Autonomy Contract:",
170
+ f" Quality floor: {len(quality.get('quality_floor') or [])} item(s)",
171
+ f" Non-negotiables: {len(quality.get('non_negotiables') or [])} item(s)",
172
+ f" Can decide: {len(autonomy.get('codex_can_decide') or [])} item(s)",
173
+ f" Must stop for: {len(autonomy.get('codex_must_stop_for') or [])} item(s)",
174
+ f" Lease: {lease.get('scope', 'phase')} / {lease.get('max_cycles', autonomy.get('max_cycles_without_user', 0))} cycle(s)",
175
+ f" Rail-check: every {rail.get('default_every_completed_tasks', 5)} completed task(s), "
176
+ f"{len(rail.get('also_check_on') or [])} trigger(s)",
177
+ f" Evidence rules: {len(draft.get('evidence_rules') or [])} item(s)",
178
+ ]
179
+
180
+
181
+ def _json_dumps(value: Any) -> str:
182
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
183
+
184
+
185
+ def _json_load(value: Any, fallback: Any) -> Any:
186
+ if value in (None, ""):
187
+ return fallback
188
+ if isinstance(value, (dict, list)):
189
+ return value
190
+ try:
191
+ return json.loads(value)
192
+ except (TypeError, json.JSONDecodeError):
193
+ return fallback
194
+
195
+
196
+ def _row_cycle(row: sqlite3.Row | None) -> dict[str, Any] | None:
197
+ if not row:
198
+ return None
199
+ data = dict(row)
200
+ for field, fallback in (
201
+ ("plan_step", {}),
202
+ ("do_log", {}),
203
+ ("check_report", {}),
204
+ ("act_log", {}),
205
+ ("evidence_summary", {}),
206
+ ):
207
+ data[field] = _json_load(data.get(field), fallback)
208
+ return data
209
+
210
+
211
+ def _task_row(conn: sqlite3.Connection, task_id: str) -> dict[str, Any] | None:
212
+ row = conn.execute(
213
+ "SELECT * FROM tasks WHERE id = ? AND deleted_at IS NULL",
214
+ (task_id,),
215
+ ).fetchone()
216
+ return dict(row) if row else None
217
+
218
+
219
+ def _contract_digest(contract: dict[str, Any]) -> str:
220
+ blob = _json_dumps({
221
+ "goal": contract.get("goal", ""),
222
+ "quality_contract": contract.get("quality_contract", {}),
223
+ "autonomy_policy": contract.get("autonomy_policy", {}),
224
+ "rail_check_policy": contract.get("rail_check_policy", {}),
225
+ "stop_rules": contract.get("stop_rules", []),
226
+ "drift_rules": contract.get("drift_rules", []),
227
+ "evidence_rules": contract.get("evidence_rules", []),
228
+ "plan_snapshot": contract.get("plan_snapshot", {}),
229
+ })
230
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
231
+
232
+
233
+ def _snapshot_from_draft(draft: dict[str, Any]) -> dict[str, Any]:
234
+ phases = []
235
+ goals = []
236
+ tasks = []
237
+ for phase in draft.get("phases", []) or []:
238
+ phases.append({
239
+ "id": phase.get("id", ""),
240
+ "name": phase.get("name", ""),
241
+ "sort_order": phase.get("sort_order", 0),
242
+ })
243
+ for goal in phase.get("goals", []) or []:
244
+ goals.append({
245
+ "id": goal.get("id", ""),
246
+ "phase_id": phase.get("id", ""),
247
+ "title": goal.get("title", ""),
248
+ "priority": goal.get("priority", 0),
249
+ })
250
+ for task in goal.get("tasks", []) or []:
251
+ tasks.append({
252
+ "id": task.get("id", ""),
253
+ "phase_id": phase.get("id", ""),
254
+ "goal_id": goal.get("id", ""),
255
+ "title": task.get("title", ""),
256
+ "priority": task.get("priority", 0),
257
+ })
258
+ for task in phase.get("unlinked_tasks", []) or []:
259
+ tasks.append({
260
+ "id": task.get("id", ""),
261
+ "phase_id": phase.get("id", ""),
262
+ "goal_id": task.get("goal_id") or "",
263
+ "title": task.get("title", ""),
264
+ "priority": task.get("priority", 0),
265
+ })
266
+ snapshot: dict[str, Any] = {
267
+ "scope": draft.get("_scope", "project"),
268
+ "level": draft.get("_level") or draft.get("level", ""),
269
+ "phases": phases,
270
+ "goals": goals,
271
+ "tasks": tasks,
272
+ "risks": draft.get("risks", []) or [],
273
+ "decisions": draft.get("decisions", []) or [],
274
+ }
275
+ if draft.get("markdown"):
276
+ snapshot["markdown"] = draft.get("markdown", "")
277
+ return snapshot
278
+
279
+
280
+ def _project_exists(conn: sqlite3.Connection, project_id: str) -> bool:
281
+ row = conn.execute(
282
+ "SELECT id FROM projects WHERE id = ? AND COALESCE(deleted_at, '') = ''",
283
+ (project_id,),
284
+ ).fetchone()
285
+ return row is not None
286
+
287
+
288
+ def _row_contract(row: sqlite3.Row | None) -> dict[str, Any] | None:
289
+ if not row:
290
+ return None
291
+ data = dict(row)
292
+ data["quality_contract"] = _json_load(data.get("quality_contract"), {})
293
+ data["autonomy_policy"] = _json_load(data.get("autonomy_policy"), {})
294
+ data["rail_check_policy"] = _json_load(data.get("rail_check_policy"), {})
295
+ data["stop_rules"] = _json_load(data.get("stop_rules"), [])
296
+ data["drift_rules"] = _json_load(data.get("drift_rules"), [])
297
+ data["evidence_rules"] = _json_load(data.get("evidence_rules"), [])
298
+ data["plan_snapshot"] = _json_load(data.get("plan_snapshot"), {})
299
+ return data
300
+
301
+
302
+ def create_plan_contract(
303
+ project_id: str,
304
+ draft: dict[str, Any],
305
+ approved_by: str = "",
306
+ source_type: str = "plan_review",
307
+ source_id: str = "",
308
+ ) -> dict[str, Any] | None:
309
+ """Convert an approved draft into a durable plan contract.
310
+
311
+ Returns None when the project row is absent. That keeps legacy unit tests
312
+ with monkeypatched state from failing, while real approvals still persist.
313
+ """
314
+ conn = get_db()
315
+ init_db(conn)
316
+ draft = apply_contract_defaults(dict(draft))
317
+ try:
318
+ if not _project_exists(conn, project_id):
319
+ return None
320
+ latest = conn.execute(
321
+ "SELECT COALESCE(MAX(version), 0) as version FROM plan_contracts WHERE project_id = ?",
322
+ (project_id,),
323
+ ).fetchone()
324
+ version = int(latest["version"] or 0) + 1
325
+ contract = {
326
+ "id": str(uuid.uuid4()),
327
+ "project_id": project_id,
328
+ "source_type": source_type,
329
+ "source_id": source_id,
330
+ "title": draft.get("title") or draft.get("phase_name") or draft.get("goal") or "Approved plan",
331
+ "goal": draft.get("goal", ""),
332
+ "version": version,
333
+ "approved_by": approved_by,
334
+ "quality_contract": draft.get("quality_contract", {}),
335
+ "autonomy_policy": draft.get("autonomy_policy", {}),
336
+ "rail_check_policy": draft.get("rail_check_policy", {}),
337
+ "stop_rules": draft.get("stop_rules", []),
338
+ "drift_rules": draft.get("drift_rules", []),
339
+ "evidence_rules": draft.get("evidence_rules", []),
340
+ "plan_snapshot": draft.get("plan_snapshot") or _snapshot_from_draft(draft),
341
+ }
342
+ contract["digest"] = _contract_digest(contract)
343
+ with transaction(conn):
344
+ conn.execute(
345
+ """UPDATE plan_contracts
346
+ SET status = 'superseded', updated_at = datetime('now')
347
+ WHERE project_id = ? AND status = 'active' AND deleted_at IS NULL""",
348
+ (project_id,),
349
+ )
350
+ conn.execute(
351
+ """INSERT INTO plan_contracts (
352
+ id, project_id, source_type, source_id, title, goal, status,
353
+ version, approved_by, approved_at, quality_contract,
354
+ autonomy_policy, rail_check_policy, stop_rules, drift_rules,
355
+ evidence_rules, plan_snapshot, digest
356
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)""",
357
+ (
358
+ contract["id"],
359
+ project_id,
360
+ source_type,
361
+ source_id,
362
+ contract["title"],
363
+ contract["goal"],
364
+ version,
365
+ approved_by,
366
+ _json_dumps(contract["quality_contract"]),
367
+ _json_dumps(contract["autonomy_policy"]),
368
+ _json_dumps(contract["rail_check_policy"]),
369
+ _json_dumps(contract["stop_rules"]),
370
+ _json_dumps(contract["drift_rules"]),
371
+ _json_dumps(contract["evidence_rules"]),
372
+ _json_dumps(contract["plan_snapshot"]),
373
+ contract["digest"],
374
+ ),
375
+ )
376
+ policies = _create_default_rail_policies(conn, project_id, contract)
377
+ log_activity(
378
+ conn,
379
+ "project",
380
+ project_id,
381
+ "plan_contract_approved",
382
+ {"contract_id": contract["id"], "version": version, "rail_policies": [p["id"] for p in policies]},
383
+ )
384
+ state = load_state()
385
+ state["active_project_id"] = project_id
386
+ state["active_plan_contract_id"] = contract["id"]
387
+ if policies:
388
+ state["active_rail_policy_id"] = policies[0]["id"]
389
+ save_state(state)
390
+ return active_plan_contract(project_id)
391
+ finally:
392
+ conn.close()
393
+
394
+
395
+ def active_plan_contract(project_id: str) -> dict[str, Any] | None:
396
+ conn = get_db()
397
+ init_db(conn)
398
+ try:
399
+ row = conn.execute(
400
+ """SELECT * FROM plan_contracts
401
+ WHERE project_id = ? AND status = 'active' AND deleted_at IS NULL
402
+ ORDER BY version DESC, created_at DESC LIMIT 1""",
403
+ (project_id,),
404
+ ).fetchone()
405
+ return _row_contract(row)
406
+ finally:
407
+ conn.close()
408
+
409
+
410
+ # ---------------------------------------------------------------------
411
+ # Task title validation (NCR-1)
412
+ # ---------------------------------------------------------------------
413
+
414
+ # Common English stopwords + frequent imperative verbs that happen to look
415
+ # like code identifiers (PascalCase or lowercase) but aren't. Without this
416
+ # filter, "Wire the new module to the editor" would extract "Wire", "new",
417
+ # "the", "module" — three of which are noise.
418
+ _TITLE_STOPWORDS: frozenset[str] = frozenset({
419
+ # articles, prepositions, conjunctions, pronouns
420
+ "the", "a", "an", "to", "and", "or", "of", "in", "on", "at", "for",
421
+ "with", "from", "by", "is", "are", "was", "were", "be", "been", "being",
422
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
423
+ "should", "may", "might", "must", "shall", "can",
424
+ "this", "that", "these", "those", "i", "you", "we", "they",
425
+ "he", "she", "it", "its", "their", "our", "your", "my",
426
+ # adverbs / quantifiers
427
+ "new", "old", "first", "last", "next", "prev", "previous",
428
+ "all", "any", "some", "no", "not", "only",
429
+ # common imperative verbs (lowercase form)
430
+ "wire", "add", "fix", "make", "update", "implement", "create", "build",
431
+ "test", "tests", "verify", "check", "run", "remove", "delete", "drop",
432
+ "migrate", "rename", "refactor", "clean", "cleanup",
433
+ "ship", "merge", "split", "merge", "land", "deploy",
434
+ })
435
+
436
+
437
+ def _extract_code_nouns(title: str) -> list[str]:
438
+ """Extract likely code-entity nouns from a task title.
439
+
440
+ Returns PascalCase (``[A-Z][a-zA-Z0-9]+``) and snake_case
441
+ (``[a-z][a-z0-9]*(_[a-z0-9]+)+``) tokens that aren't English stopwords.
442
+ Order is preserved, duplicates are removed (first occurrence wins).
443
+ """
444
+ pascal = re.findall(r"\b[A-Z][a-zA-Z0-9]+\b", title)
445
+ # Non-capturing group so re.findall returns the full match, not the last
446
+ # captured underscore-prefixed segment.
447
+ snake = re.findall(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b", title)
448
+ seen: set[str] = set()
449
+ result: list[str] = []
450
+ for n in pascal + snake:
451
+ if n.lower() in _TITLE_STOPWORDS:
452
+ continue
453
+ if n in seen:
454
+ continue
455
+ seen.add(n)
456
+ result.append(n)
457
+ return result
458
+
459
+
460
+ def _find_unresolved_refs(title: str, src_root: str) -> dict[str, list[str]]:
461
+ """Check which code-noun tokens in ``title`` don't match anything in ``src_root``.
462
+
463
+ Returns ``{"resolved": [...], "unresolved": [...]}``. When ``src_root``
464
+ does not exist, both lists are empty (caller decides policy).
465
+ """
466
+ nouns = _extract_code_nouns(title)
467
+ src_path = Path(src_root)
468
+ if not src_path.exists():
469
+ return {"resolved": [], "unresolved": []}
470
+ # Build set of identifier-like tokens harvested from filenames under src/.
471
+ found: set[str] = set()
472
+ for p in src_path.rglob("*"):
473
+ if not p.is_file():
474
+ continue
475
+ stem = p.stem
476
+ if stem:
477
+ found.add(stem)
478
+ for token in re.findall(r"[A-Z][a-zA-Z0-9]+|[a-z][a-z0-9]*(?:_[a-z0-9]+)+", stem):
479
+ found.add(token)
480
+ resolved: list[str] = []
481
+ unresolved: list[str] = []
482
+ for n in nouns:
483
+ if n in found:
484
+ resolved.append(n)
485
+ else:
486
+ unresolved.append(n)
487
+ return {"resolved": resolved, "unresolved": unresolved}