context-rail 0.2.0__tar.gz → 0.2.2__tar.gz

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 (112) hide show
  1. {context_rail-0.2.0/src/context_rail.egg-info → context_rail-0.2.2}/PKG-INFO +1 -1
  2. {context_rail-0.2.0 → context_rail-0.2.2}/pyproject.toml +36 -36
  3. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/_tool_surface.py +5 -0
  4. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_find.py +47 -9
  5. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_lifecycle.py +23 -0
  6. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_notes.py +93 -0
  7. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_plan_review.py +112 -0
  8. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_tasks.py +146 -19
  9. context_rail-0.2.2/src/context_rail/skills/daily/SKILL.md +114 -0
  10. context_rail-0.2.2/src/context_rail/skills/governance/SKILL.md +135 -0
  11. context_rail-0.2.2/src/context_rail/skills/orchestration/SKILL.md +168 -0
  12. context_rail-0.2.2/src/context_rail/skills/setup/SKILL.md +249 -0
  13. context_rail-0.2.2/src/context_rail/skills/wiki/SKILL.md +227 -0
  14. {context_rail-0.2.0 → context_rail-0.2.2/src/context_rail.egg-info}/PKG-INFO +1 -1
  15. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/SOURCES.txt +5 -0
  16. {context_rail-0.2.0 → context_rail-0.2.2}/LICENSE +0 -0
  17. {context_rail-0.2.0 → context_rail-0.2.2}/README.md +0 -0
  18. {context_rail-0.2.0 → context_rail-0.2.2}/setup.cfg +0 -0
  19. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/__init__.py +0 -0
  20. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/__main__.py +0 -0
  21. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/__init__.py +0 -0
  22. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_contracts.py +0 -0
  23. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_drift.py +0 -0
  24. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_evidence_harvest.py +0 -0
  25. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_intent_match.py +0 -0
  26. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_pdca.py +0 -0
  27. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_rails.py +0 -0
  28. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_runs.py +0 -0
  29. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_runtime.py +0 -0
  30. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy.py +0 -0
  31. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/__init__.py +0 -0
  32. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/_phase_drafts.py +0 -0
  33. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/_plan_drafts.py +0 -0
  34. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/drafts.py +0 -0
  35. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_artifacts.py +0 -0
  36. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_conductor.py +0 -0
  37. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_core.py +0 -0
  38. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_synthesis.py +0 -0
  39. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/cli.py +0 -0
  40. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/context_pack.py +0 -0
  41. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db.py +0 -0
  42. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/__init__.py +0 -0
  43. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/_gates.py +0 -0
  44. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/_schema.py +0 -0
  45. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema.py +0 -0
  46. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/focus.py +0 -0
  47. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/gates.py +0 -0
  48. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/rules.py +0 -0
  49. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/__init__.py +0 -0
  50. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/_imports.py +0 -0
  51. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_artifacts.py +0 -0
  52. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_autonomy.py +0 -0
  53. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_brainstorm.py +0 -0
  54. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_categories.py +0 -0
  55. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_configure.py +0 -0
  56. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_context.py +0 -0
  57. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_decisions.py +0 -0
  58. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_focus.py +0 -0
  59. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_gates.py +0 -0
  60. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_intent.py +0 -0
  61. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_orient.py +0 -0
  62. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_phase_brainstorm.py +0 -0
  63. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_planning.py +0 -0
  64. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_projects.py +0 -0
  65. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_retro.py +0 -0
  66. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_risks.py +0 -0
  67. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_roadmap.py +0 -0
  68. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_rules.py +0 -0
  69. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_runtime.py +0 -0
  70. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server.py +0 -0
  71. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/dependency_links.txt +0 -0
  72. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/entry_points.txt +0 -0
  73. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/requires.txt +0 -0
  74. {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/top_level.txt +0 -0
  75. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_agent_lanes.py +0 -0
  76. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_ambient_context.py +0 -0
  77. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_autonomy_drift.py +0 -0
  78. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_autonomy_record.py +0 -0
  79. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_brainstorm.py +0 -0
  80. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_brainstorm_tools.py +0 -0
  81. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_cli_agents.py +0 -0
  82. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_context_learning_loop.py +0 -0
  83. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_db.py +0 -0
  84. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_deps.py +0 -0
  85. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_evidence_harvest.py +0 -0
  86. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_evidence_validation.py +0 -0
  87. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_find.py +0 -0
  88. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_focus_tools.py +0 -0
  89. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_gates.py +0 -0
  90. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_governance_mode.py +0 -0
  91. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_governance_tiers.py +0 -0
  92. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_integration_lifecycle.py +0 -0
  93. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_intent_match.py +0 -0
  94. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_non_blocking_pdca.py +0 -0
  95. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_notes.py +0 -0
  96. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_orient.py +0 -0
  97. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_parallel_chat_safety.py +0 -0
  98. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_plan_review.py +0 -0
  99. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_project_create.py +0 -0
  100. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_retro.py +0 -0
  101. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_routing.py +0 -0
  102. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_runtime_check.py +0 -0
  103. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_runtime_integration.py +0 -0
  104. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_schema_invariants.py +0 -0
  105. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_smoke_v2.py +0 -0
  106. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_smoke_vibe.py +0 -0
  107. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_task_lifecycle.py +0 -0
  108. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_task_title_validation.py +0 -0
  109. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_tool_registry_integrity.py +0 -0
  110. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_tool_surface.py +0 -0
  111. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_usability_fixes.py +0 -0
  112. {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-rail
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Context Rail — agentic task-management that keeps your project on rails.
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/nguyenhoanhson797/Context-Rail
@@ -1,36 +1,36 @@
1
- [build-system]
2
- requires = ["setuptools>=68", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "context-rail"
7
- version = "0.2.0"
8
- description = "Context Rail — agentic task-management that keeps your project on rails."
9
- readme = "README.md"
10
- license = {text = "MIT"}
11
- requires-python = ">=3.11"
12
- dependencies = [
13
- "mcp>=1.0.0",
14
- ]
15
-
16
- [project.optional-dependencies]
17
- web = [
18
- "context-rail-web",
19
- ]
20
- test = [
21
- "pytest>=8",
22
- "pytest-asyncio>=0.24",
23
- "fastapi>=0.115",
24
- ]
25
-
26
- [project.scripts]
27
- ctx-rail = "context_rail.cli:main"
28
-
29
- [project.urls]
30
- Repository = "https://github.com/nguyenhoanhson797/Context-Rail"
31
-
32
- [tool.setuptools.packages.find]
33
- where = ["src"]
34
-
35
- [tool.setuptools.package-data]
36
- context_rail = []
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "context-rail"
7
+ version = "0.2.2"
8
+ description = "Context Rail — agentic task-management that keeps your project on rails."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "mcp>=1.0.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ web = [
18
+ "context-rail-web",
19
+ ]
20
+ test = [
21
+ "pytest>=8",
22
+ "pytest-asyncio>=0.24",
23
+ "fastapi>=0.115",
24
+ ]
25
+
26
+ [project.scripts]
27
+ ctx-rail = "context_rail.cli:main"
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/nguyenhoanhson797/Context-Rail"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ context_rail = ["skills/**/*"]
@@ -24,6 +24,10 @@ CORE_TOOLS = frozenset({
24
24
  "task_edit",
25
25
  "done",
26
26
  "block",
27
+ # Phase discovery (NCR-1)
28
+ "phase_tasks",
29
+ # Contract management (B5/NCR-3)
30
+ "contract_refresh",
27
31
  # Discovery
28
32
  "find",
29
33
  # Session
@@ -40,6 +44,7 @@ CORE_TOOLS = frozenset({
40
44
  # Knowledge capture
41
45
  "note",
42
46
  "learn",
47
+ "quick_learn",
43
48
  "decision_create",
44
49
  # Review
45
50
  "retro",
@@ -5,12 +5,15 @@ from ._imports import * # noqa: F403
5
5
 
6
6
 
7
7
  @mcp.tool()
8
- async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
8
+ async def find(query: str, workdir: str = "", agent_id: str = "", phase_id: str = "") -> str:
9
9
  """Search tasks, goals, lessons, notes, risks, and artifacts by keyword.
10
10
 
11
11
  Searches titles AND descriptions. Also detects entity-type keywords
12
12
  (\"task\", \"goal\", \"phase\", \"roadmap\", etc.) and includes recent items
13
13
  of that type even when the keyword doesn't appear in their text.
14
+
15
+ Pass phase_id to filter results to tasks and goals in a specific phase.
16
+ When phase_id is provided without a query, returns all tasks/goals in that phase.
14
17
  """
15
18
  conn = get_db()
16
19
  init_db(conn)
@@ -22,6 +25,16 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
22
25
  q = f"%{query}%"
23
26
  q_lower = query.strip().lower()
24
27
 
28
+ # B2 fix: validate phase_id if provided
29
+ if phase_id:
30
+ phase = conn.execute(
31
+ "SELECT id FROM phases WHERE id = ? AND project_id = ? AND deleted_at IS NULL",
32
+ (phase_id, pid),
33
+ ).fetchone()
34
+ if not phase:
35
+ conn.close()
36
+ return json.dumps({"error": f"Phase {phase_id} not found in project {pid}"})
37
+
25
38
  # Entity-type keywords: when the user types "task", also list recent tasks
26
39
  type_keywords = {
27
40
  "task": ("tasks", "SELECT id, title, status, priority, goal_id, assignee FROM tasks WHERE project_id = ? AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10"),
@@ -36,16 +49,32 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
36
49
  }
37
50
 
38
51
  # --- Tasks ---
39
- tasks = conn.execute("""SELECT id, title, status, priority, goal_id, assignee, description
40
- FROM tasks WHERE project_id = ? AND (title LIKE ? OR description LIKE ? OR assignee LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 20""",
41
- (pid, q, q, q),
42
- ).fetchall()
52
+ if phase_id:
53
+ tasks = conn.execute("""SELECT id, title, status, priority, goal_id, assignee, description
54
+ FROM tasks WHERE project_id = ? AND phase_id = ?
55
+ AND (title LIKE ? OR description LIKE ? OR assignee LIKE ? OR ? = '')
56
+ AND deleted_at IS NULL ORDER BY priority DESC, sort_order ASC LIMIT 50""",
57
+ (pid, phase_id, q, q, q, query),
58
+ ).fetchall()
59
+ else:
60
+ tasks = conn.execute("""SELECT id, title, status, priority, goal_id, assignee, description
61
+ FROM tasks WHERE project_id = ? AND (title LIKE ? OR description LIKE ? OR assignee LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 20""",
62
+ (pid, q, q, q),
63
+ ).fetchall()
43
64
 
44
65
  # --- Goals ---
45
- goals = conn.execute("""SELECT id, title, status, priority, description
46
- FROM goals WHERE project_id = ? AND (title LIKE ? OR description LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10""",
47
- (pid, q, q),
48
- ).fetchall()
66
+ if phase_id:
67
+ goals = conn.execute("""SELECT id, title, status, priority, description
68
+ FROM goals WHERE project_id = ? AND phase_id = ?
69
+ AND (title LIKE ? OR description LIKE ? OR ? = '')
70
+ AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10""",
71
+ (pid, phase_id, q, q, query),
72
+ ).fetchall()
73
+ else:
74
+ goals = conn.execute("""SELECT id, title, status, priority, description
75
+ FROM goals WHERE project_id = ? AND (title LIKE ? OR description LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10""",
76
+ (pid, q, q),
77
+ ).fetchall()
49
78
 
50
79
  # --- Entity-type fallback: include recent items of the requested type ---
51
80
  entity_fallbacks: dict[str, list] = {}
@@ -138,6 +167,15 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
138
167
  else:
139
168
  result["_note"] = f"Also showing recent {key} (entity-type match)."
140
169
 
170
+ # B2 fix: annotate result when filtered by phase
171
+ if phase_id:
172
+ phase_name = conn.execute(
173
+ "SELECT name FROM phases WHERE id = ?", (phase_id,)
174
+ ).fetchone()
175
+ result["_phase_id"] = phase_id
176
+ if phase_name:
177
+ result["_phase_name"] = phase_name["name"]
178
+
141
179
  conn.close()
142
180
  return json.dumps(result, indent=2)
143
181
 
@@ -109,6 +109,29 @@ async def session_start(agent_id: str = "", workdir: str = "") -> str:
109
109
  "progress": rd.get("progress", 0),
110
110
  }
111
111
 
112
+ # --- NCR-2: include all phase tasks (compact) for discovery ---
113
+ result["phase_tasks"] = []
114
+ if active_phase:
115
+ phase_tasks = conn.execute(
116
+ """SELECT id, title, status, priority, lane, goal_id, sort_order, blocked_reason
117
+ FROM tasks
118
+ WHERE project_id = ? AND phase_id = ? AND deleted_at IS NULL
119
+ ORDER BY sort_order ASC, priority DESC""",
120
+ (pid, active_phase["id"]),
121
+ ).fetchall()
122
+ result["phase_tasks"] = [
123
+ {
124
+ "id": t["id"],
125
+ "title": t["title"],
126
+ "status": t["status"],
127
+ "priority": t["priority"],
128
+ "lane": t["lane"] or "normal",
129
+ "sort_order": t["sort_order"],
130
+ "blocked_reason": t["blocked_reason"],
131
+ }
132
+ for t in phase_tasks
133
+ ]
134
+
112
135
  # --- Next task ---
113
136
  next_task = find_next_task(conn, pid)
114
137
  if next_task and not next_task.get("_no_available_task"):
@@ -100,6 +100,99 @@ async def note(content: str, kind: str = "note", task_id: str = "", workdir: str
100
100
  return f"Project note saved ({kind}). Use find(\"{title[:30]}\") to search."
101
101
 
102
102
 
103
+ @mcp.tool()
104
+ async def quick_learn(
105
+ content: str,
106
+ kind: str = "",
107
+ task_id: str = "",
108
+ workdir: str = "",
109
+ agent_id: str = "",
110
+ ) -> str:
111
+ """One-param capture with auto-inference. Same friction as memory.add().
112
+
113
+ Auto-infers:
114
+ - task_id from active task (if not provided)
115
+ - kind from content patterns ("bug" if contains 'bug', 'fix'/'fixed', etc.)
116
+ - scope (task-scoped when task_id is available, project-scoped otherwise)
117
+
118
+ Saves as both a lesson and a note — same content, dual persistence.
119
+ Use this for quick bug findings, fixes, and observations during execution.
120
+ """
121
+ conn = get_db()
122
+ init_db(conn)
123
+
124
+ # Auto-infer task_id
125
+ if not task_id:
126
+ task_id = load_state().get("active_task_id", "")
127
+
128
+ # Auto-infer kind
129
+ if not kind:
130
+ content_lower = content.lower()
131
+ if any(w in content_lower for w in ("bug", "crash", "error", "null", "undefined", "typeerror", "traceback", "fails")):
132
+ kind = "pitfall"
133
+ elif any(w in content_lower for w in ("fixed", "fix", "resolved", "patch", "workaround")):
134
+ kind = "pattern"
135
+ else:
136
+ kind = "decision"
137
+
138
+ # Save as lesson (auto-links to task)
139
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
140
+ if not pid:
141
+ conn.close()
142
+ return "No active project. Call project_login or project_set_active first."
143
+
144
+ tag_list = ["quick_learn"]
145
+ lid = str(uuid.uuid4())
146
+
147
+ # Resolve FK fields from task
148
+ gid = None
149
+ phase_id = None
150
+ if task_id:
151
+ task = conn.execute(
152
+ "SELECT goal_id, phase_id FROM tasks WHERE id = ? AND deleted_at IS NULL",
153
+ (task_id,),
154
+ ).fetchone()
155
+ if task:
156
+ gid = task["goal_id"]
157
+ phase_id = task["phase_id"]
158
+
159
+ conn.execute(
160
+ """INSERT INTO lessons (id, project_id, goal_id, phase_id, task_id, content, kind, tags, last_verified_at)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))""",
162
+ (lid, pid, gid, phase_id, task_id, content, kind, json.dumps(tag_list)),
163
+ )
164
+ log_activity(conn, "lesson", lid, "created", {"kind": kind, "content": content[:100], "_via": "quick_learn"})
165
+
166
+ # Also save as note on the task (dual persistence)
167
+ nid = ""
168
+ if task_id:
169
+ nid = str(uuid.uuid4())
170
+ conn.execute(
171
+ "INSERT INTO task_context (id, task_id, content, kind) VALUES (?, ?, ?, ?)",
172
+ (nid, task_id, content, kind),
173
+ )
174
+ log_activity(conn, "task_context", nid, "created",
175
+ {"kind": kind, "_via": "quick_learn"})
176
+
177
+ conn.commit()
178
+ conn.close()
179
+
180
+ attach_activity_to_active_cycle(
181
+ pid,
182
+ task_id=task_id or "",
183
+ kind="lesson",
184
+ summary=content,
185
+ entity_type="lesson",
186
+ entity_id=lid,
187
+ metadata={"lesson_kind": kind, "tags": tag_list},
188
+ )
189
+
190
+ msg = f"Quick-learn saved ({kind}): lesson={lid}"
191
+ if nid:
192
+ msg += f", note={nid}"
193
+ return msg
194
+
195
+
103
196
  @mcp.tool()
104
197
  async def learn(
105
198
  content: str,
@@ -518,3 +518,115 @@ async def plan_contract_view(contract_id: str = "", workdir: str = "") -> str:
518
518
  pass # Never break view on annotation failure
519
519
 
520
520
  return json.dumps(contract, indent=2)
521
+
522
+
523
+ @mcp.tool()
524
+ async def contract_refresh(contract_id: str = "", workdir: str = "") -> str:
525
+ """Refresh the plan contract snapshot from live project data.
526
+
527
+ Re-snapshots the active (or specified) contract with current task titles,
528
+ statuses, goals, and phases. Eliminates stale-plan confusion from
529
+ _snapshot_drift by updating the snapshot to match reality.
530
+
531
+ Returns the refreshed contract with before/after drift summary.
532
+ """
533
+ state = load_state()
534
+ pid = state.get("active_project_id") if state else resolve_pid_for_tool(workdir)
535
+ if not pid:
536
+ pid = resolve_pid_for_tool(workdir)
537
+ if not pid:
538
+ return json.dumps({"error": "No active project"})
539
+
540
+ contract = active_plan_contract(pid) if not contract_id else None
541
+ if contract_id:
542
+ conn = get_db()
543
+ init_db(conn)
544
+ try:
545
+ row = conn.execute(
546
+ """SELECT * FROM plan_contracts
547
+ WHERE id = ? AND project_id = ? AND deleted_at IS NULL""",
548
+ (contract_id, pid),
549
+ ).fetchone()
550
+ if row:
551
+ from ..autonomy._contracts import _row_contract
552
+ contract = _row_contract(row)
553
+ finally:
554
+ conn.close()
555
+
556
+ if not contract:
557
+ return json.dumps({"error": "No active plan contract found"})
558
+
559
+ contract_cid = contract["id"]
560
+
561
+ conn = get_db()
562
+ init_db(conn)
563
+ try:
564
+ # Rebuild snapshot from live data
565
+ phases = conn.execute(
566
+ """SELECT id, name, sort_order FROM phases
567
+ WHERE project_id = ? AND deleted_at IS NULL
568
+ ORDER BY sort_order ASC""",
569
+ (pid,),
570
+ ).fetchall()
571
+
572
+ goals = conn.execute(
573
+ """SELECT id, phase_id, title, priority FROM goals
574
+ WHERE project_id = ? AND deleted_at IS NULL""",
575
+ (pid,),
576
+ ).fetchall()
577
+
578
+ tasks = conn.execute(
579
+ """SELECT id, phase_id, goal_id, title, priority, status
580
+ FROM tasks
581
+ WHERE project_id = ? AND deleted_at IS NULL""",
582
+ (pid,),
583
+ ).fetchall()
584
+
585
+ new_snapshot = {
586
+ "scope": contract.get("plan_snapshot", {}).get("scope", "project"),
587
+ "level": contract.get("plan_snapshot", {}).get("level", ""),
588
+ "phases": [{"id": p["id"], "name": p["name"], "sort_order": p["sort_order"]}
589
+ for p in phases],
590
+ "goals": [{"id": g["id"], "phase_id": g["phase_id"], "title": g["title"],
591
+ "priority": g["priority"]} for g in goals],
592
+ "tasks": [{"id": t["id"], "phase_id": t["phase_id"], "goal_id": t["goal_id"],
593
+ "title": t["title"], "priority": t["priority"], "status": t["status"]}
594
+ for t in tasks],
595
+ }
596
+
597
+ # Compute drift between old and new snapshots
598
+ old_snapshot = contract.get("plan_snapshot", {})
599
+ old_titles = {t["id"]: t.get("title") for t in old_snapshot.get("tasks", [])}
600
+ new_titles = {t["id"]: t["title"] for t in tasks}
601
+ drift = [
602
+ {"task_id": tid, "snapshot_title": old_titles[tid], "live_title": new_titles[tid]}
603
+ for tid in old_titles if tid in new_titles and old_titles[tid] != new_titles[tid]
604
+ ]
605
+
606
+ # Update digest
607
+ from ..autonomy._contracts import _contract_digest
608
+ updated_contract = dict(contract)
609
+ updated_contract["plan_snapshot"] = new_snapshot
610
+ new_digest = _contract_digest(updated_contract)
611
+
612
+ conn.execute(
613
+ """UPDATE plan_contracts
614
+ SET plan_snapshot = ?, digest = ?, updated_at = datetime('now')
615
+ WHERE id = ?""",
616
+ (json.dumps(new_snapshot, sort_keys=True, separators=(",", ":")),
617
+ new_digest, contract_cid),
618
+ )
619
+ log_activity(conn, "project", pid, "contract_refreshed",
620
+ {"contract_id": contract_cid, "resolved_drifts": len(drift)})
621
+ conn.commit()
622
+ finally:
623
+ conn.close()
624
+
625
+ return json.dumps({
626
+ "contract_id": contract_cid,
627
+ "version": contract.get("version", 1),
628
+ "refreshed": True,
629
+ "resolved_drifts": len(drift),
630
+ "drift_resolved": drift if drift else [],
631
+ "_note": "Snapshot updated from live data. Call plan_contract_view() to see the refreshed contract.",
632
+ }, indent=2)
@@ -222,12 +222,27 @@ def _context_checkpoint(task_id: str) -> dict:
222
222
  """Check if the agent touched any context items for this task.
223
223
 
224
224
  Returns:
225
- dict with items_presented, items_verified, items_applied, items_updated,
226
- and should_warn (True if items were presented but none were touched).
225
+ dict with items_presented, items_touched, and should_warn.
226
+ should_warn is True when items were presented but none were touched,
227
+ AND no prior checkpoint warning was logged for this task in the last hour.
228
+ On the second call the warning is always skipped (speed bump, not wall).
227
229
  """
228
230
  conn = get_db()
229
231
  init_db(conn)
230
232
  try:
233
+ # --- B1 fix: check if already warned for this task recently ---
234
+ prior_warn = conn.execute(
235
+ """SELECT COUNT(*) as cnt FROM activity_log
236
+ WHERE entity_type = 'task'
237
+ AND entity_id = ?
238
+ AND action = 'context_checkpoint_warned'
239
+ AND created_at > datetime('now', '-1 hour')""",
240
+ (task_id,),
241
+ ).fetchone()
242
+ if prior_warn and prior_warn["cnt"] > 0:
243
+ return {"should_warn": False, "items_presented": 0, "items_touched": 0,
244
+ "_bypass": "second_call"}
245
+
231
246
  # Count context items visible to this task (same scope resolution as focus)
232
247
  task = conn.execute(
233
248
  "SELECT t.*, p.roadmap_id FROM tasks t "
@@ -236,8 +251,8 @@ def _context_checkpoint(task_id: str) -> dict:
236
251
  (task_id,),
237
252
  ).fetchone()
238
253
  if not task:
239
- return {"should_warn": False, "items_presented": 0, "items_verified": 0,
240
- "items_applied": 0, "items_updated": 0}
254
+ return {"should_warn": False, "items_presented": 0,
255
+ "items_touched": 0}
241
256
 
242
257
  task_dict = dict(task)
243
258
  gid = task_dict.get("goal_id") or ""
@@ -246,26 +261,29 @@ def _context_checkpoint(task_id: str) -> dict:
246
261
  rid = task_dict.get("roadmap_id") or ""
247
262
  pid = task_dict.get("project_id") or ""
248
263
 
249
- # Count items that would be presented (exclude expand_mode=never)
264
+ # D1 fix: only count task-scoped items and required_for_completion items.
265
+ # Noisy project/phase-scoped reference items are excluded from the gate.
250
266
  conditions = ["project_id = ?", "deleted_at IS NULL", "expand_mode != 'never'"]
251
267
  params: list = [pid]
252
268
 
253
- # Build OR condition for scopes: task, goal, phase, category, roadmap, project
254
- scope_parts = ["(scope_type = 'task' AND scope_id = ?)"]
269
+ scope_parts = []
270
+ # Task-scoped items are always relevant
271
+ scope_parts.append("(scope_type = 'task' AND scope_id = ?)")
255
272
  params.append(task_id)
256
273
  if gid:
257
- scope_parts.append("(scope_type = 'goal' AND scope_id = ?)")
274
+ scope_parts.append("(scope_type = 'goal' AND scope_id = ? AND required_for_completion = 1)")
258
275
  params.append(gid)
259
276
  if phid:
260
- scope_parts.append("(scope_type = 'phase' AND scope_id = ?)")
277
+ scope_parts.append("(scope_type = 'phase' AND scope_id = ? AND required_for_completion = 1)")
261
278
  params.append(phid)
262
279
  if cid:
263
- scope_parts.append("(scope_type = 'category' AND scope_id = ?)")
280
+ scope_parts.append("(scope_type = 'category' AND scope_id = ? AND required_for_completion = 1)")
264
281
  params.append(cid)
265
282
  if rid:
266
- scope_parts.append("(scope_type = 'roadmap' AND scope_id = ?)")
283
+ scope_parts.append("(scope_type = 'roadmap' AND scope_id = ? AND required_for_completion = 1)")
267
284
  params.append(rid)
268
- scope_parts.append("(scope_type = 'project' AND scope_id = ?)")
285
+ # Project-scoped items only count if required_for_completion
286
+ scope_parts.append("(scope_type = 'project' AND scope_id = ? AND required_for_completion = 1)")
269
287
  params.append(pid)
270
288
 
271
289
  count_sql = (
@@ -276,8 +294,8 @@ def _context_checkpoint(task_id: str) -> dict:
276
294
  items_presented = count_row["cnt"] if count_row else 0
277
295
 
278
296
  if items_presented == 0:
279
- return {"should_warn": False, "items_presented": 0, "items_verified": 0,
280
- "items_applied": 0, "items_updated": 0}
297
+ return {"should_warn": False, "items_presented": 0,
298
+ "items_touched": 0}
281
299
 
282
300
  # Check recent activity log for context actions on items linked to this task's scope
283
301
  # We check: context_verify, context_apply, context_update in the activity log
@@ -294,8 +312,8 @@ def _context_checkpoint(task_id: str) -> dict:
294
312
  ctx_ids = [r["id"] for r in ctx_ids_rows]
295
313
 
296
314
  if not ctx_ids:
297
- return {"should_warn": False, "items_presented": 0, "items_verified": 0,
298
- "items_applied": 0, "items_updated": 0}
315
+ return {"should_warn": False, "items_presented": 0,
316
+ "items_touched": 0}
299
317
 
300
318
  # Check activity log for context actions on these items recently (last 24h)
301
319
  # This is more accurate than checking applied_count (which accumulates across tasks)
@@ -351,11 +369,18 @@ async def done(task_id: str) -> str:
351
369
  conn.close()
352
370
 
353
371
  # --- Context checkpoint: soft gate ---
354
- # If context items were presented to this task and none were touched
355
- # (verified/applied/updated), warn the agent on first done() call.
356
- # The second done() call always succeeds — this is a speed bump, not a wall.
372
+ # B1 fix: On first done() call, items_presented > 0 with no touches triggers a
373
+ # warning and logs the event. The second done() call finds the log entry and
374
+ # skips the checkpoint entirely, delivering on the "always succeed" guarantee.
357
375
  checkpoint = _context_checkpoint(task_id)
358
376
  if checkpoint.get("should_warn"):
377
+ # Log the warning so the second call can detect and bypass it
378
+ conn = get_db()
379
+ init_db(conn)
380
+ log_activity(conn, "task", task_id, "context_checkpoint_warned",
381
+ {"items_presented": checkpoint["items_presented"],
382
+ "items_touched": checkpoint["items_touched"]})
383
+ conn.close()
359
384
  return json.dumps({
360
385
  "warning": "context_checkpoint",
361
386
  "message": (
@@ -937,6 +962,108 @@ async def task_bulk_delete(task_ids: str = "") -> str:
937
962
  }, indent=2)
938
963
 
939
964
 
965
+ # ---------------------------------------------------------------------------
966
+ # NCR-1: Phase tasks endpoint — list all tasks in a phase
967
+ # ---------------------------------------------------------------------------
968
+
969
+ @mcp.tool()
970
+ async def phase_tasks(
971
+ phase_id: str = "",
972
+ workdir: str = "",
973
+ agent_id: str = "",
974
+ ) -> str:
975
+ """List all tasks in a phase. Returns tasks grouped by goal with status,
976
+ priority, lane, and dependencies. Use this to discover what needs to be
977
+ done in a phase without speculative find() calls.
978
+
979
+ If phase_id is omitted, returns tasks for the active (first non-completed) phase.
980
+ """
981
+ conn = get_db()
982
+ init_db(conn)
983
+ pid = resolve_pid_for_tool(workdir, agent_id)
984
+ if not pid:
985
+ conn.close()
986
+ return json.dumps({"error": "No active project"})
987
+
988
+ if not phase_id:
989
+ phase = conn.execute(
990
+ """SELECT id, name FROM phases
991
+ WHERE project_id = ? AND status != 'completed' AND deleted_at IS NULL
992
+ ORDER BY sort_order ASC LIMIT 1""",
993
+ (pid,),
994
+ ).fetchone()
995
+ if not phase:
996
+ conn.close()
997
+ return json.dumps({"error": "No active phase found"})
998
+ phase_id = phase["id"]
999
+ phase_name = phase["name"]
1000
+ else:
1001
+ phase_row = conn.execute(
1002
+ "SELECT id, name FROM phases WHERE id = ? AND project_id = ? AND deleted_at IS NULL",
1003
+ (phase_id, pid),
1004
+ ).fetchone()
1005
+ if not phase_row:
1006
+ conn.close()
1007
+ return json.dumps({"error": f"Phase {phase_id} not found"})
1008
+ phase_name = phase_row["name"]
1009
+
1010
+ tasks = conn.execute(
1011
+ """SELECT t.id, t.title, t.status, t.priority, t.lane, t.goal_id,
1012
+ t.parent_task_id, t.assignee, t.blocked_reason, t.planned,
1013
+ t.sort_order, t.estimated_hours, t.review_required
1014
+ FROM tasks t
1015
+ WHERE t.project_id = ? AND t.phase_id = ? AND t.deleted_at IS NULL
1016
+ ORDER BY t.sort_order ASC, t.priority DESC""",
1017
+ (pid, phase_id),
1018
+ ).fetchall()
1019
+
1020
+ goals = conn.execute(
1021
+ """SELECT g.id, g.title, g.status, g.priority
1022
+ FROM goals g
1023
+ WHERE g.project_id = ? AND g.phase_id = ? AND g.deleted_at IS NULL
1024
+ ORDER BY g.priority DESC""",
1025
+ (pid, phase_id),
1026
+ ).fetchall()
1027
+
1028
+ total = len(tasks)
1029
+ completed = sum(1 for t in tasks if t["status"] == "completed")
1030
+ blocked = sum(1 for t in tasks if t["status"] == "blocked")
1031
+ pending = total - completed - blocked
1032
+
1033
+ # Group tasks by goal
1034
+ goal_map: dict[str, dict] = {g["id"]: {**dict(g), "tasks": []} for g in goals}
1035
+ unlinked: list[dict] = []
1036
+
1037
+ for t in tasks:
1038
+ td = dict(t)
1039
+ gid = td.get("goal_id")
1040
+ if gid and gid in goal_map:
1041
+ goal_map[gid]["tasks"].append(td)
1042
+ else:
1043
+ unlinked.append(td)
1044
+
1045
+ result = {
1046
+ "phase_id": phase_id,
1047
+ "phase_name": phase_name,
1048
+ "summary": {
1049
+ "total": total,
1050
+ "completed": completed,
1051
+ "pending": pending,
1052
+ "blocked": blocked,
1053
+ },
1054
+ "goals": [
1055
+ {"id": gid, "title": g["title"], "status": g["status"],
1056
+ "priority": g["priority"], "task_count": len(g["tasks"]),
1057
+ "tasks": g["tasks"]}
1058
+ for gid, g in goal_map.items() if g["tasks"]
1059
+ ],
1060
+ "unlinked_tasks": unlinked,
1061
+ }
1062
+
1063
+ conn.close()
1064
+ return json.dumps(result, indent=2)
1065
+
1066
+
940
1067
  # ---------------------------------------------------------------------------
941
1068
  # Project notes CRUD (update/delete)
942
1069
  # ---------------------------------------------------------------------------