Step-by-step guide
Connect planning to your system of record
Keep meaningful review and approval while choosing where plans are managed.
What this changes
A team may want agent plans to live beside the repository or inside an established planning system. Clarvis keeps the planning contract stable so that storage does not weaken review semantics.
-
01
One planning home
Agent work can participate in the system where the rest of the team already coordinates.
-
02
Approval with meaning
The reviewed intent remains distinguishable from later changes.
-
03
Less process duplication
Teams do not need a separate planning practice solely because an agent is involved.
Minimum setup
Start with one working configuration.
The runtime keeps plan validation, transitions, and approval semantics. Your provider owns durable reads, listings, reconciliation, deletion, and atomic compare-and-swap writes.
# plans_service.py
# A complete plan provider. It answers every method the plan data plane sends,
# and it passes the shared store conformance suite as written.
import json
import sys
PLANS = {}
def send(message):
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
def result(request_id, value):
send({"jsonrpc": "2.0", "id": request_id, "result": value})
def failure(request_id, code, message, extra=None):
data = {"code": code}
if extra:
data.update(extra)
send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32001, "message": message, "data": data},
}
)
def plans_for(owner):
return PLANS.setdefault(owner, {})
def handle(request):
request_id = request["id"]
method = request["method"]
params = request.get("params") or {}
owner = params.get("owner", "")
if method == "initialize":
result(request_id, {"protocol_version": 1, "provider_kind": "acme-tracker"})
return
if method == "shutdown":
result(request_id, None)
return
store = plans_for(owner)
if method == "plans/create":
document = params["document"]
store[document["id"]] = document
result(request_id, document)
return
if method == "plans/read":
document = store.get(params["id"])
if document is None:
failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
return
result(request_id, document)
return
if method == "plans/list":
query = params.get("input") or {}
rows = list(store.values())
if query.get("status") is not None:
rows = [row for row in rows if row["status"] == query["status"]]
if query.get("retention") is not None:
rows = [row for row in rows if row["retention"] == query["retention"]]
rows.sort(key=lambda row: row["created_at"], reverse=True)
offset = int(query.get("cursor") or 0)
limit = max(1, min(100, int(query.get("limit") or 20)))
page = rows[offset : offset + limit]
payload = {"plans": page}
if offset + len(page) < len(rows):
payload["next_cursor"] = str(offset + len(page))
result(request_id, payload)
return
if method == "plans/write":
current = store.get(params["id"])
if current is None:
failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
return
expected = params["expected"]
if (
current["revision"] != expected["revision"]
or current["digest"] != expected["digest"]
or current["spec_digest"] != expected["specDigest"]
):
failure(
request_id,
"plan_conflict",
"Plan changed since it was read",
{"reason": "cas"},
)
return
store[params["id"]] = params["document"]
result(request_id, params["document"])
return
if method == "plans/reconcile":
document = store.get(params["id"])
if document is None:
failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
return
result(request_id, document)
return
if method == "plans/delete":
result(request_id, store.pop(params["id"], None) is not None)
return
failure(request_id, "plan_invalid", "unknown method %s" % method)
for line in sys.stdin:
line = line.strip()
if line:
handle(json.loads(line))
The command starts without a shell and resolves relative arguments from the workspace root. Select the provider in ~/.clarvis/settings.json: an executable or plugin provider named in a repository's own .clarvis/settings.json is a workspace risk field, so it is stripped from the merge until you approve that workspace with /workspace-trust, and until then planning falls back to the built-in Markdown store without raising an error.
Working patterns
Adapt it to work you actually do.
Open a pattern to see the complete files and the reason behind each choice.
Pattern 01 ~/.clarvis/settings.json Configuration +
The service above does nothing until a scope selects it. Declare it per user, where the selection takes effect immediately. The command starts without a shell and relative arguments resolve from the workspace root, so plans_service.py is found when you start Clarvis in the checkout that holds it. The same block placed in a repository's own .clarvis/settings.json is withheld instead: an executable or plugin provider is a workspace risk field, stripped from the merge until you approve that workspace with /workspace-trust, and until then planning uses the built-in Markdown store and reports no error. Settings are strict JSON, so this file carries no comments.
{
"plans": {
"mode": "on",
"retention": "keep",
"provider": {
"kind": "executable",
"command": "python3",
"args": ["-B", "plans_service.py"],
"timeout_ms": 30000
}
}
}
Pattern 02 Keep plans in a database instead of a dictionary Configuration +
The skeleton loses every plan when the process exits, and it cannot answer a filtered listing without walking everything. A real provider needs durable rows and an index it can query. The port stores the plan as an opaque payload and keeps a small projection beside it precisely so a backend that cannot search inside the document still serves a filtered page.
# The parts that change from the skeleton. Everything else is identical.
import sqlite3
DB = sqlite3.connect("plans.db")
DB.execute(
"""CREATE TABLE IF NOT EXISTS plans (
owner TEXT NOT NULL,
id TEXT NOT NULL,
revision INTEGER NOT NULL,
digest TEXT NOT NULL,
spec_digest TEXT NOT NULL,
status TEXT NOT NULL,
retention TEXT NOT NULL,
created_at TEXT NOT NULL,
document TEXT NOT NULL,
PRIMARY KEY (owner, id))"""
)
DB.commit()
# plans/write becomes one conditional UPDATE, which is what makes the
# compare and swap atomic instead of a read followed by a write.
expected = params["expected"]
document = params["document"]
cursor = DB.execute(
"UPDATE plans SET revision = ?, digest = ?, spec_digest = ?, status = ?,"
" retention = ?, document = ?"
" WHERE owner = ? AND id = ? AND revision = ? AND digest = ? AND spec_digest = ?",
(
document["revision"],
document["digest"],
document["spec_digest"],
document["status"],
document["retention"],
json.dumps(document),
owner,
params["id"],
expected["revision"],
expected["digest"],
expected["specDigest"],
),
)
DB.commit()
if cursor.rowcount == 1:
result(request_id, document)
elif row_of(owner, params["id"]) is None:
failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
else:
failure(request_id, "plan_conflict", "Plan changed since it was read", {"reason": "cas"})
# plans/list filters in SQL and pages with an integer offset cursor.
sql = "SELECT document FROM plans WHERE owner = ?"
args = [owner]
if query.get("status") is not None:
sql += " AND status = ?"
args.append(query["status"])
if query.get("retention") is not None:
sql += " AND retention = ?"
args.append(query["retention"])
sql += " ORDER BY created_at DESC, id DESC"
rows = [json.loads(r[0]) for r in DB.execute(sql, args).fetchall()]
Pattern 03 Let a change made in your system reach the running agent Configuration +
Someone closes a task in your tracker while the agent is mid run. Without a way to publish that back, the agent keeps working a stale copy and you are back to two lists. This is what plans/reconcile is for: the run calls it before every plan mutation and before it reads its own plan, so it is the one place your system gets to say "this moved".
# In plans/reconcile, return the current document. When your own system
# changed it, advance the counters and change the digest so the caller
# treats it as a new revision rather than as the one it already held.
if method == "plans/reconcile":
document = store.get(params["id"])
if document is None:
failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
return
updated = apply_tracker_state(document) # your mapping, your business
if updated is not document:
updated["revision"] = document["revision"] + 1
updated["digest"] = new_opaque_token()
# bump spec_revision too, but only when the plan's substance moved:
# its objective, context, validation, or a task's title/detail/exit.
store[params["id"]] = updated
result(request_id, updated)
return
# The run adopts what you returned as its new baseline, so the next
# mutation it sends carries your revision and digest and passes your
# compare and swap check. A conflict raised in the meantime is not fatal:
# the run re-reads and continues.
Verify the behavior
Check the boundary, not just the happy path.
Use a small disposable task first. Confirm what works and what is deliberately unavailable before relying on the configuration in a real workflow.
- 01
The provider initializes with the expected protocol version before plan work begins.
- 02
A write checks revision, digest, and spec digest atomically before replacing the stored document.
- 03
A change made in the external system reaches the agent through reconciliation instead of creating a second plan.