context-rail 0.2.2__tar.gz → 0.2.4__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.2/src/context_rail.egg-info → context_rail-0.2.4}/PKG-INFO +1 -1
  2. {context_rail-0.2.2 → context_rail-0.2.4}/pyproject.toml +1 -1
  3. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/cli.py +59 -19
  4. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/db.py +38 -4
  5. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/_imports.py +2 -0
  6. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_lifecycle.py +5 -3
  7. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_orient.py +9 -5
  8. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_plan_review.py +2 -4
  9. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_planning.py +7 -8
  10. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_projects.py +6 -6
  11. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_risks.py +3 -2
  12. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_roadmap.py +6 -4
  13. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/skills/setup/SKILL.md +25 -15
  14. {context_rail-0.2.2 → context_rail-0.2.4/src/context_rail.egg-info}/PKG-INFO +1 -1
  15. {context_rail-0.2.2 → context_rail-0.2.4}/LICENSE +0 -0
  16. {context_rail-0.2.2 → context_rail-0.2.4}/README.md +0 -0
  17. {context_rail-0.2.2 → context_rail-0.2.4}/setup.cfg +0 -0
  18. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/__init__.py +0 -0
  19. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/__main__.py +0 -0
  20. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/__init__.py +0 -0
  21. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_contracts.py +0 -0
  22. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_drift.py +0 -0
  23. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_evidence_harvest.py +0 -0
  24. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_intent_match.py +0 -0
  25. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_pdca.py +0 -0
  26. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_rails.py +0 -0
  27. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_runs.py +0 -0
  28. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy/_runtime.py +0 -0
  29. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/autonomy.py +0 -0
  30. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm/__init__.py +0 -0
  31. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm/_phase_drafts.py +0 -0
  32. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm/_plan_drafts.py +0 -0
  33. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm/drafts.py +0 -0
  34. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm_artifacts.py +0 -0
  35. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm_conductor.py +0 -0
  36. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm_core.py +0 -0
  37. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/brainstorm_synthesis.py +0 -0
  38. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/context_pack.py +0 -0
  39. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/db_schema/__init__.py +0 -0
  40. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/db_schema/_gates.py +0 -0
  41. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/db_schema/_schema.py +0 -0
  42. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/db_schema.py +0 -0
  43. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/focus.py +0 -0
  44. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/gates.py +0 -0
  45. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/rules.py +0 -0
  46. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/__init__.py +0 -0
  47. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/_tool_surface.py +0 -0
  48. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_artifacts.py +0 -0
  49. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_autonomy.py +0 -0
  50. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_brainstorm.py +0 -0
  51. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_categories.py +0 -0
  52. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_configure.py +0 -0
  53. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_context.py +0 -0
  54. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_decisions.py +0 -0
  55. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_find.py +0 -0
  56. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_focus.py +0 -0
  57. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_gates.py +0 -0
  58. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_intent.py +0 -0
  59. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_notes.py +0 -0
  60. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_phase_brainstorm.py +0 -0
  61. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_retro.py +0 -0
  62. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_rules.py +0 -0
  63. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_runtime.py +0 -0
  64. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server/tools_tasks.py +0 -0
  65. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/server.py +0 -0
  66. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/skills/daily/SKILL.md +0 -0
  67. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/skills/governance/SKILL.md +0 -0
  68. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/skills/orchestration/SKILL.md +0 -0
  69. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail/skills/wiki/SKILL.md +0 -0
  70. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail.egg-info/SOURCES.txt +0 -0
  71. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail.egg-info/dependency_links.txt +0 -0
  72. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail.egg-info/entry_points.txt +0 -0
  73. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail.egg-info/requires.txt +0 -0
  74. {context_rail-0.2.2 → context_rail-0.2.4}/src/context_rail.egg-info/top_level.txt +0 -0
  75. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_agent_lanes.py +0 -0
  76. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_ambient_context.py +0 -0
  77. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_autonomy_drift.py +0 -0
  78. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_autonomy_record.py +0 -0
  79. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_brainstorm.py +0 -0
  80. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_brainstorm_tools.py +0 -0
  81. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_cli_agents.py +0 -0
  82. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_context_learning_loop.py +0 -0
  83. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_db.py +0 -0
  84. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_deps.py +0 -0
  85. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_evidence_harvest.py +0 -0
  86. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_evidence_validation.py +0 -0
  87. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_find.py +0 -0
  88. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_focus_tools.py +0 -0
  89. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_gates.py +0 -0
  90. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_governance_mode.py +0 -0
  91. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_governance_tiers.py +0 -0
  92. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_integration_lifecycle.py +0 -0
  93. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_intent_match.py +0 -0
  94. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_non_blocking_pdca.py +0 -0
  95. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_notes.py +0 -0
  96. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_orient.py +0 -0
  97. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_parallel_chat_safety.py +0 -0
  98. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_plan_review.py +0 -0
  99. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_project_create.py +0 -0
  100. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_retro.py +0 -0
  101. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_routing.py +0 -0
  102. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_runtime_check.py +0 -0
  103. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_runtime_integration.py +0 -0
  104. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_schema_invariants.py +0 -0
  105. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_smoke_v2.py +0 -0
  106. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_smoke_vibe.py +0 -0
  107. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_task_lifecycle.py +0 -0
  108. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_task_title_validation.py +0 -0
  109. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_tool_registry_integrity.py +0 -0
  110. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_tool_surface.py +0 -0
  111. {context_rail-0.2.2 → context_rail-0.2.4}/tests/test_usability_fixes.py +0 -0
  112. {context_rail-0.2.2 → context_rail-0.2.4}/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.2
3
+ Version: 0.2.4
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "context-rail"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Context Rail — agentic task-management that keeps your project on rails."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -5,7 +5,7 @@ import json
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
- from .db import DB_DIR, DB_PATH, STATE_PATH, get_db, init_db, load_state
8
+ from .db import DB_DIR, DB_PATH, STATE_PATH, get_db, init_db, load_state, resolve_workspace_project, write_local_project, find_local_project
9
9
 
10
10
 
11
11
 
@@ -85,9 +85,22 @@ def ensure_agents_md(project_id: str = "", project_name: str = "") -> None:
85
85
  agents_path.write_text(existing.rstrip() + "\n\n" + wrapped, encoding="utf-8")
86
86
 
87
87
 
88
+ def _ensure_gitignore_has_context_rail(cwd: str) -> None:
89
+ """Add .context-rail to .gitignore if a .gitignore exists but doesn't mention it."""
90
+ gi_path = Path(cwd) / ".gitignore"
91
+ if not gi_path.is_file():
92
+ return
93
+ content = gi_path.read_text(encoding="utf-8")
94
+ if ".context-rail" in content:
95
+ return
96
+ gi_path.write_text(content.rstrip() + "\n\n.context-rail\n", encoding="utf-8")
97
+
98
+
88
99
  def _active_project_for_agents_md() -> tuple[str, str]:
89
- state = load_state()
90
- project_id = state.get("active_project_id")
100
+ from pathlib import Path
101
+ project_id, _ = resolve_workspace_project(str(Path.cwd()))
102
+ if not project_id:
103
+ project_id = load_state().get("active_project_id", "")
91
104
  if not project_id:
92
105
  return "", ""
93
106
 
@@ -234,8 +247,10 @@ def cmd_doctor() -> int:
234
247
 
235
248
  # --- 3. State ---
236
249
  print("\nState:")
237
- state = load_state()
238
- pid = state.get("active_project_id")
250
+ from pathlib import Path
251
+ pid, _ = resolve_workspace_project(str(Path.cwd()))
252
+ if not pid:
253
+ pid = load_state().get("active_project_id", "")
239
254
  if pid:
240
255
  ok(f"Active project: {pid}")
241
256
  else:
@@ -337,18 +352,45 @@ def cmd_init() -> None:
337
352
  print(" AGENTS.md: generated in current directory")
338
353
  print(" Skills: context-rail skills installed to .agents/skills/")
339
354
  else:
340
- state = load_state()
341
- pid = state.get("active_project_id")
355
+ from pathlib import Path
356
+ cwd = str(Path.cwd())
357
+ pid = ""
358
+ resolution = ""
359
+
360
+ # 1. Local .context-rail file
361
+ pid = find_local_project(cwd)
362
+ if pid:
363
+ resolution = ".context-rail file"
364
+ # 2. Workspace binding
365
+ if not pid:
366
+ pid, tier = resolve_workspace_project(cwd)
367
+ if pid:
368
+ resolution = f"workspace binding ({tier})"
369
+ # 3. Global state fallback
370
+ if not pid:
371
+ pid = load_state().get("active_project_id", "")
372
+ if pid:
373
+ resolution = "global state fallback"
374
+
342
375
  if pid:
343
- proj = get_db().execute("SELECT name FROM projects WHERE id = ?", (pid,)).fetchone()
344
- if proj:
345
- print(f"Active project: {proj['name']}")
376
+ proj = get_db().execute(
377
+ "SELECT name, deleted_at FROM projects WHERE id = ?", (pid,)
378
+ ).fetchone()
379
+ if proj and not proj["deleted_at"]:
380
+ source = "" if resolution == ".context-rail file" else f" (source: {resolution})"
381
+ print(f"Active project: {proj['name']}{source}")
346
382
  ensure_agents_md(pid, proj["name"])
383
+ write_local_project(cwd, pid)
384
+ _ensure_gitignore_has_context_rail(cwd)
385
+ elif proj and proj["deleted_at"]:
386
+ print(f"Active project: {proj['name']} (DELETED — run project_set_active() for a new project)")
387
+ ensure_agents_md()
347
388
  else:
348
- print(f"Active project not found (stale ID). Run project_set_active().")
389
+ print(f"Stale project ID '{pid}' found in {resolution}. Run project_set_active().")
349
390
  ensure_agents_md()
350
391
  else:
351
- print(f"{projects} project(s) exist but none is active. Run project_set_active().")
392
+ print(f"{projects} project(s) exist but none is active.")
393
+ print("Run project_set_active(project_id=\"...\", workdir=\".\") to bind this directory.")
352
394
  ensure_agents_md()
353
395
  ensure_skills()
354
396
  print(" AGENTS.md: updated in current directory")
@@ -441,14 +483,12 @@ def cmd_status(deleted: bool = False) -> None:
441
483
  "SELECT COUNT(*) as cnt FROM projects WHERE deleted_at IS NOT NULL"
442
484
  ).fetchone()["cnt"]
443
485
  conn.close()
444
- state = load_state()
445
486
 
446
- print(f"DB: {DB_PATH}")
447
- print(f"Projects: {active_count} active")
448
- if deleted_count > 0:
449
- print(f" {deleted_count} soft-deleted (use --deleted to list)")
450
- pid = state.get("active_project_id")
451
- tid = state.get("active_task_id")
487
+ from pathlib import Path
488
+ pid, _ = resolve_workspace_project(str(Path.cwd()))
489
+ if not pid:
490
+ pid = load_state().get("active_project_id", "")
491
+ tid = load_state().get("active_task_id")
452
492
  if pid:
453
493
  proj = get_db().execute(
454
494
  "SELECT name, deleted_at FROM projects WHERE id = ?", (pid,)
@@ -3,8 +3,11 @@
3
3
 
4
4
  import sqlite3
5
5
  import json
6
+ import os
6
7
  from pathlib import Path
7
8
 
9
+ CONTEXT_RAIL_FILE = ".context-rail"
10
+
8
11
 
9
12
  from contextlib import contextmanager
10
13
 
@@ -130,18 +133,46 @@ def set_workspace_binding(workdir: str, project_id: str, agent_id: str = "") ->
130
133
  save_workspace_bindings(bindings)
131
134
 
132
135
 
136
+ def find_local_project(cwd: str) -> str:
137
+ """Walk up from cwd looking for a .context-rail file containing a project_id.
138
+
139
+ Returns project_id string, or "" if none found.
140
+ """
141
+ directory = Path(cwd).resolve()
142
+ while True:
143
+ cr_file = directory / CONTEXT_RAIL_FILE
144
+ if cr_file.is_file():
145
+ try:
146
+ return cr_file.read_text().strip()
147
+ except (OSError, UnicodeDecodeError):
148
+ pass
149
+ parent = directory.parent
150
+ if parent == directory:
151
+ break
152
+ directory = parent
153
+ return ""
154
+
155
+
156
+ def write_local_project(workdir: str, project_id: str) -> None:
157
+ """Write a .context-rail file in the given directory pointing to the project."""
158
+ cr_file = Path(workdir) / CONTEXT_RAIL_FILE
159
+ try:
160
+ cr_file.write_text(project_id + "\n")
161
+ except OSError:
162
+ pass # non-blocking — directory may not be writable
163
+
164
+
133
165
  def resolve_pid_for_tool(workdir: str = "", agent_id: str = "") -> str:
134
166
  """Resolve the active project_id for a tool call.
135
167
 
136
168
  Order of resolution:
137
169
 
170
+ 0. Local .context-rail file in cwd (or ancestor) — auto-created by
171
+ project_create / project_set_active / ctx-rail init
138
172
  1. Explicit workdir parameter on the call → workspace binding lookup
139
173
  2. Default workdir = Path.cwd() (MCP subprocess inherits chat's cwd)
140
174
  3. Workspace binding lookup with the effective workdir
141
- 4. Fall back to state.json ``active_project_id`` — needed because
142
- many tools (plan_phase, plan_goal, tools_risks, etc.) still read
143
- state.json directly, so a project created via project_create()
144
- (which writes state.json) must be visible here too.
175
+ 4. Fall back to state.json ``active_project_id``
145
176
  5. Empty string (caller decides what to do — usually fail with
146
177
  "No active project")
147
178
 
@@ -152,6 +183,9 @@ def resolve_pid_for_tool(workdir: str = "", agent_id: str = "") -> str:
152
183
  from pathlib import Path
153
184
  workdir = str(Path.cwd())
154
185
  if workdir:
186
+ local_pid = find_local_project(workdir)
187
+ if local_pid:
188
+ return local_pid
155
189
  bound_pid, _tier = resolve_workspace_project(workdir, agent_id)
156
190
  if bound_pid:
157
191
  return bound_pid
@@ -41,6 +41,8 @@ _DELEGATES: dict[str, tuple] = {
41
41
  "resolve_workspace_project": (_db, "resolve_workspace_project"),
42
42
  "set_workspace_binding": (_db, "set_workspace_binding"),
43
43
  "resolve_pid_for_tool": (_db, "resolve_pid_for_tool"),
44
+ "find_local_project": (_db, "find_local_project"),
45
+ "write_local_project": (_db, "write_local_project"),
44
46
  "rows_to_list": (_db, "rows_to_list"),
45
47
  "log_activity": (_db, "log_activity"),
46
48
  "clock_in_agent_for_task": (_db, "clock_in_agent_for_task"),
@@ -33,9 +33,11 @@ async def session_start(agent_id: str = "", workdir: str = "") -> str:
33
33
  workdir = str(Path.cwd())
34
34
 
35
35
  if workdir:
36
- bound_pid, tier = resolve_workspace_project(workdir, agent_id)
37
- if bound_pid:
38
- pid = bound_pid
36
+ pid = find_local_project(workdir)
37
+ if not pid:
38
+ bound_pid, tier = resolve_workspace_project(workdir, agent_id)
39
+ if bound_pid:
40
+ pid = bound_pid
39
41
 
40
42
  if not pid:
41
43
  state_pid = load_state().get("active_project_id")
@@ -44,7 +44,7 @@ async def orient(agent_id: str = "", workdir: str = "") -> str:
44
44
  conn = get_db()
45
45
  init_db(conn)
46
46
 
47
- # --- Resolve project: workspace binding > workdir > cwd > state fallback ---
47
+ # --- Resolve project: .context-rail > workspace binding > workdir > cwd > state fallback ---
48
48
  pid = ""
49
49
  binding_tier = ""
50
50
 
@@ -56,10 +56,14 @@ async def orient(agent_id: str = "", workdir: str = "") -> str:
56
56
  workdir = str(Path.cwd())
57
57
 
58
58
  if workdir:
59
- bound_pid, tier = resolve_workspace_project(workdir, agent_id)
60
- binding_tier = tier
61
- if bound_pid:
62
- pid = bound_pid
59
+ pid = find_local_project(workdir)
60
+ if pid:
61
+ binding_tier = "local_file"
62
+ else:
63
+ bound_pid, tier = resolve_workspace_project(workdir, agent_id)
64
+ binding_tier = tier
65
+ if bound_pid:
66
+ pid = bound_pid
63
67
 
64
68
  if not pid:
65
69
  # Fallback: state.json active_project_id — keeps orient()
@@ -436,8 +436,7 @@ async def plan_contract_view(contract_id: str = "", workdir: str = "") -> str:
436
436
  kind) or a verifiable artifact path/URL (manual_check). See
437
437
  `default_evidence_rules()` in autonomy/_contracts.py.
438
438
  """
439
- state = load_state()
440
- pid = state.get("active_project_id") if state else resolve_pid_for_tool(workdir)
439
+ pid = resolve_pid_for_tool(workdir) or load_state().get("active_project_id", "")
441
440
  # Fall back to helper if state is empty (NCR from state.json removal)
442
441
  if not pid:
443
442
  pid = resolve_pid_for_tool(workdir)
@@ -530,8 +529,7 @@ async def contract_refresh(contract_id: str = "", workdir: str = "") -> str:
530
529
 
531
530
  Returns the refreshed contract with before/after drift summary.
532
531
  """
533
- state = load_state()
534
- pid = state.get("active_project_id") if state else resolve_pid_for_tool(workdir)
532
+ pid = resolve_pid_for_tool(workdir) or load_state().get("active_project_id", "")
535
533
  if not pid:
536
534
  pid = resolve_pid_for_tool(workdir)
537
535
  if not pid:
@@ -11,12 +11,13 @@ async def plan_goal(
11
11
  priority: int = 0,
12
12
  goal_type: str = "objective",
13
13
  planned: bool = True,
14
+ workdir: str = "",
15
+ agent_id: str = "",
14
16
  ) -> str:
15
17
  """Add a goal to the current active phase."""
16
18
  conn = get_db()
17
19
  init_db(conn)
18
- state = load_state()
19
- pid = state.get("active_project_id")
20
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
20
21
 
21
22
  if not pid:
22
23
  return "No active project."
@@ -39,12 +40,11 @@ async def plan_goal(
39
40
 
40
41
 
41
42
  @mcp.tool()
42
- async def plan_phase(name: str, target_date: str = "", sort_order: int = 0) -> str:
43
+ async def plan_phase(name: str, target_date: str = "", sort_order: int = 0, workdir: str = "", agent_id: str = "") -> str:
43
44
  """Add a new phase to the active project."""
44
45
  conn = get_db()
45
46
  init_db(conn)
46
- state = load_state()
47
- pid = state.get("active_project_id")
47
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
48
48
 
49
49
  if not pid:
50
50
  return "No active project."
@@ -74,12 +74,11 @@ async def plan_phase(name: str, target_date: str = "", sort_order: int = 0) -> s
74
74
 
75
75
 
76
76
  @mcp.tool()
77
- async def phase_reorder(phase_id: str, new_sort_order: int) -> str:
77
+ async def phase_reorder(phase_id: str, new_sort_order: int, workdir: str = "", agent_id: str = "") -> str:
78
78
  """Move a phase to a new position. Other phases are rebalanced automatically."""
79
79
  conn = get_db()
80
80
  init_db(conn)
81
- state = load_state()
82
- pid = state.get("active_project_id")
81
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
83
82
 
84
83
  if not pid:
85
84
  return "No active project."
@@ -68,6 +68,7 @@ async def project_login(project_id: str, agent_id: str = "", workdir: str = "")
68
68
  binding_msg = f" Workspace {workdir} bound (agent {agent_id})."
69
69
  else:
70
70
  binding_msg = " (existing wildcard binding preserved; pass agent_id to bind specifically)"
71
+ write_local_project(workdir, project_id)
71
72
  else:
72
73
  binding_msg = " (pass workdir to bind workspace)"
73
74
 
@@ -109,6 +110,7 @@ async def project_create(name: str, description: str = "", budget: str = "medium
109
110
 
110
111
  if workdir:
111
112
  set_workspace_binding(workdir, pid)
113
+ write_local_project(workdir, pid)
112
114
 
113
115
  # Update AGENTS.md
114
116
  try:
@@ -221,16 +223,15 @@ async def project_list(project_id: str = "", include_deleted: bool = False) -> s
221
223
  # ---------------------------------------------------------------------------
222
224
 
223
225
  @mcp.tool()
224
- async def project_budget(budget: str = "medium", project_id: str = "") -> str:
226
+ async def project_budget(budget: str = "medium", project_id: str = "", workdir: str = "", agent_id: str = "") -> str:
225
227
  """Set the project budget. Controls how deep auto-expand goes: small(3), medium(10), high(25), max.
226
228
 
227
229
  Lower budgets save tokens during orient/focus. Use expand_all=True on focus() to bypass.
228
230
  """
229
231
  conn = get_db()
230
232
  init_db(conn)
231
- state = load_state()
232
233
 
233
- pid = project_id or state.get("active_project_id")
234
+ pid = project_id or resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
234
235
  if not pid:
235
236
  conn.close()
236
237
  return json.dumps({"error": "No project"})
@@ -249,13 +250,12 @@ async def project_budget(budget: str = "medium", project_id: str = "") -> str:
249
250
 
250
251
 
251
252
  @mcp.tool()
252
- async def project_update(project_id: str = "", name: str = "", description: str = "", budget: str = "", status: str = "", userConfirm: bool = False) -> str:
253
+ async def project_update(project_id: str = "", name: str = "", description: str = "", budget: str = "", status: str = "", userConfirm: bool = False, workdir: str = "", agent_id: str = "") -> str:
253
254
  """Update a project. Uses active project if project_id is empty. To change status, set userConfirm=True."""
254
255
  conn = get_db()
255
256
  init_db(conn)
256
- state = load_state()
257
257
 
258
- pid = project_id or state.get("active_project_id")
258
+ pid = project_id or resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
259
259
  if not pid:
260
260
  conn.close()
261
261
  return json.dumps({"error": "No project"})
@@ -10,6 +10,8 @@ async def risk(
10
10
  severity: str = "medium",
11
11
  description: str = "",
12
12
  entity_type: str = "task",
13
+ workdir: str = "",
14
+ agent_id: str = "",
13
15
  ) -> str:
14
16
  """Flag a risk on a task or goal (severity: low|medium|high|critical)."""
15
17
  conn = get_db()
@@ -38,8 +40,7 @@ async def risk(
38
40
  # Auto-create enforcement rules for high/critical risks
39
41
  derived_msg = ""
40
42
  if severity in ("high", "critical"):
41
- state = load_state()
42
- pid = state.get("active_project_id")
43
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
43
44
  if pid:
44
45
  # For critical: block retries; for high: warn on retries
45
46
  action = "block" if severity == "critical" else "warn"
@@ -38,6 +38,8 @@ async def roadmap_add(
38
38
  tags: str = "",
39
39
  visibility: str = "team",
40
40
  order_idx: int = 0,
41
+ workdir: str = "",
42
+ agent_id: str = "",
41
43
  ) -> str:
42
44
  """Create a new roadmap item.
43
45
 
@@ -49,8 +51,7 @@ async def roadmap_add(
49
51
 
50
52
  conn = get_db()
51
53
  init_db(conn)
52
- state = load_state()
53
- pid = state.get("active_project_id")
54
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
54
55
  if not pid:
55
56
  conn.close()
56
57
  return json.dumps({"error": "No active project"})
@@ -172,6 +173,8 @@ async def roadmap_list(
172
173
  status: str = "",
173
174
  parent_id: str = "",
174
175
  visibility: str = "",
176
+ workdir: str = "",
177
+ agent_id: str = "",
175
178
  ) -> str:
176
179
  """List roadmap items with optional filters.
177
180
 
@@ -179,8 +182,7 @@ async def roadmap_list(
179
182
  """
180
183
  conn = get_db()
181
184
  init_db(conn)
182
- state = load_state()
183
- pid = state.get("active_project_id")
185
+ pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
184
186
  if not pid:
185
187
  conn.close()
186
188
  return json.dumps({"error": "No active project"})
@@ -45,36 +45,45 @@ Skills are auto-copied to `.agents/skills/context-rail/` in the current project.
45
45
  ```
46
46
  1. project_create(name="My Project", description="What it does", workdir="/path/to/repo")
47
47
  → Creates project in database, auto-generates AGENTS.md
48
- → Optionally binds the workspace
48
+ → Creates .context-rail file + workspace binding in workdir
49
49
 
50
50
  2. project_set_active(project_id="...", workdir="/path/to/repo")
51
- → Sets as active project in state.json
52
- → Creates workspace binding (agent resolves project from cwd)
51
+ → Sets as active project in state.json (global fallback)
52
+ → Creates .context-rail file + workspace binding
53
+ → After this, any command/tool in that directory auto-resolves
53
54
 
54
55
  3. project_login(project_id, agent_id="...", workdir="/path/to/repo")
55
56
  → Alternative: log an agent into the project with workspace binding
56
57
  → Call this when orient() shows ⚠ unbound
58
+
59
+ 4. ctx-rail init (run in project root)
60
+ → Auto-detects project from .context-rail file, workspace binding, or state.json
61
+ → Creates .context-rail file if a project resolves
62
+ → Adds .context-rail to .gitignore (if .gitignore exists)
57
63
  ```
58
64
 
59
65
  ### Verifying setup
60
66
 
61
67
  ```
62
68
  orient(workdir="/path/to/repo")
63
- → Should show: "Login: ✓ bound as '<agent>' in this workspace"
64
- → If shows "⚠ no workspace binding" → run project_login or project_set_active
69
+ → Should show: "Login: ✓ bound via .context-rail"
70
+ → If shows "⚠ unbound" → run ctx-rail init or project_set_active with workdir
65
71
  ```
66
72
 
67
- ## Workspace Binding (how agents resolve projects)
73
+ ## Project Resolution (how agents find the right project)
68
74
 
69
- Context Rail resolves the active project from the agent's current working directory:
75
+ Context Rail resolves the active project from the agent's current working directory
76
+ using a 3-tier approach:
70
77
 
71
- | Method | How it works | When to use |
72
- |---|---|---|
73
- | `project_set_active(workdir="/repo")` | Binds workspace → project (wildcard `*`) | Any agent in that directory gets this project |
74
- | `project_login(agent_id="a1", workdir="/repo")` | Binds workspace → project for specific agent | Multiple agents in same directory, different projects |
75
- | `orient(workdir="/repo")` | Queries bindings → falls back to state.json | Called automatically on session startup |
78
+ | Tier | Method | How it works | Automatic? |
79
+ |---|---|---|---|
80
+ | 1 | `.context-rail` file | File in cwd (or parent) containing project_id | Created on `ctx-rail init`, `project_create`, `project_set_active` |
81
+ | 2 | Workspace binding | Global mapping in `workspace_bindings.json` | Created when workdir is passed to `project_set_active` |
82
+ | 3 | State fallback | `state.json`'s `active_project_id` | Legacy — only used when tiers 1 & 2 miss |
76
83
 
77
- All 113 MCP tools use `resolve_pid_for_tool(workdir, agent_id)` first, then fall back to `load_state()`. Workspace bindings take priority.
84
+ All tools use `resolve_pid_for_tool(workdir, agent_id)` which checks all three tiers
85
+ in order. After one `ctx-rail init`, you never need `project_set_active` again —
86
+ the `.context-rail` file handles it automatically.
78
87
 
79
88
  ## Loading Skills
80
89
 
@@ -208,8 +217,9 @@ project_login(project_id="...", agent_id="...", workdir="/path/to/repo")
208
217
 
209
218
  ### "No active project" on tools that should work
210
219
 
211
- The tool may be using `load_state()` only (legacy). All tools should now use workspace binding. If a specific tool still fails:
212
- 1. Pass `workdir="/path/to/repo"` explicitly
220
+ 1. Run `ctx-rail init` in the project root to create `.context-rail`
221
+ 2. Pass `workdir="/path/to/repo"` explicitly
222
+ 3. Call `project_set_active(project_id="...", workdir="/path/to/repo")` to bind the directory
213
223
  2. Call `project_set_active(workdir="/path/to/repo")` first
214
224
  3. Report the tool name — it may still need the workspace binding fix
215
225
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-rail
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
File without changes
File without changes
File without changes