Add plan lineage CLI helpers

This commit is contained in:
HumbleTurtle
2026-05-17 09:00:47 -03:00
parent f320ea655f
commit aa15a677f9
5 changed files with 398 additions and 3 deletions
+32
View File
@@ -0,0 +1,32 @@
# Agent Instructions
This file is bootstrap guidance only. Napseer is the canonical project memory source.
## Operating Order
For non-trivial work:
1. Consult BotAI MCP for read-only advisory guidance.
2. Query Napseer for current project memory.
3. Inspect this repository or runtime before changing anything.
4. Make the smallest scoped change that satisfies the task.
5. Verify with focused proof.
6. Record durable outcomes in Napseer when behavior, product direction, security, plans, or operations change.
## Memory Source
Use Napseer as the only project memory source. Do not create scattered Markdown files for internal project understanding. Repository Markdown is only for bootstrap instructions or intentional public/versioned documentation.
Start with:
- `/rules/memory-source-policy`
- `/documentation/product/ideal-mcp-and-cli`
- `/documentation/features/gateway-terminal`
- `/indexes/project-memory-map`
- `/documentation/product/prd`
## Repository Scope
This repository owns the operator-facing `nap` CLI, local MCP wrapper installation/update UX, project/auth/status commands, memory ergonomics helpers, and gateway command facade. Backend discovery remains the compatibility source of truth; authenticated project memory writes go through the local MCP wrapper.
When CLI or MCP wrapper behavior changes, update the relevant Napseer node in the same work and verify with the focused CLI/MCP tests.
+9 -1
View File
@@ -646,6 +646,10 @@ def main(argv):
if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args)
if command == "lineage":
return run_wrapper("lineage", args or ["status"])
if command == "plan":
return run_wrapper("plan", args or ["list"])
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
@@ -670,12 +674,16 @@ def main(argv):
)
return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]")
print("Usage: nap [auth|project|plan|lineage|gateway|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption.")
print(" Examples: nap project create, nap project status, nap project encryption status")
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" plan Plan commands: list, to-kanban.")
print(" Example: nap plan to-kanban /plans/example --column todo")
print(" lineage Generic source-record -> plan -> kanban lineage checks.")
print(" Example: nap lineage status")
print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.")
print(" Start auto-selects a local port; use --port to pin one.")
print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat")
+264 -1
View File
@@ -143,6 +143,21 @@ KANBAN_COLUMN_LIFECYCLE = {
"review": "review",
"done": "done",
}
PLANNING_SOURCE_RELATIONS = {
"created_from",
"derived_from",
"implements",
"references",
"tracks",
"verifies",
}
PLAN_ORIGIN_RELATIONS = {
"created_from",
"derived_from",
"implements",
"references",
"tracks",
}
def discovery_max_limit(view):
@@ -8220,6 +8235,166 @@ def create_kanban_card(args):
}
def normalize_relation_slug(value):
return re.sub(r"[\s-]+", "_", str(value or "").strip().lower())
def is_plan_node_path(path):
parts = [part for part in normalize_node_path(path).strip("/").split("/") if part]
return len(parts) >= 2 and parts[0] == "plans"
def is_kanban_card_node_path(path):
parts = [part for part in normalize_node_path(path).strip("/").split("/") if part]
return len(parts) >= 3 and parts[0] == "kanban"
def plan_source_paths(node):
paths = []
for link in node.get("links") or []:
if not isinstance(link, dict):
continue
path = normalize_node_path(link.get("path") or "")
relation = normalize_relation_slug(link.get("relation") or "references")
if path and not is_plan_node_path(path) and not is_kanban_card_node_path(path) and relation in PLANNING_SOURCE_RELATIONS:
paths.append(path)
metadata = node_metadata(node)
for key in ("source_record_path", "source_path", "requirement_path", "spec_path"):
value = metadata.get(key)
if value not in (None, ""):
path = normalize_node_path(value)
if path and path not in paths:
paths.append(path)
return unique_nonempty_strings(paths)
def card_source_plan_path(node):
for link in node.get("links") or []:
if not isinstance(link, dict):
continue
path = normalize_node_path(link.get("path") or "")
relation = normalize_relation_slug(link.get("relation") or "references")
if is_plan_node_path(path) and relation in PLAN_ORIGIN_RELATIONS:
return path
metadata = node_metadata(node)
for key in ("source_plan_path", "origin_plan_path", "plan_path"):
value = metadata.get(key)
if value not in (None, ""):
path = normalize_node_path(value)
if is_plan_node_path(path):
return path
return None
def lineage_status(args):
args = dict(args or {})
limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, DISCOVERY_COMPACT_MAX_LIMIT)
include_archived = as_bool(args.get("include_archived"), False)
include_items = as_bool(args.get("include_items"), True)
plans = list_project_nodes({
"folder_path": "/plans",
"limit": limit,
"view": "full",
"include_archived": include_archived,
}).get("items", [])
kanban_nodes = list_project_nodes({
"q": "/kanban",
"limit": limit,
"view": "full",
"include_archived": True,
}).get("items", [])
cards = [node for node in kanban_nodes if is_kanban_card_node_path(node.get("full_path") or "")]
plan_items = []
card_items = []
warnings = []
for plan in plans:
sources = plan_source_paths(plan)
item = summarize_node(plan, view="metadata")
item["source_record_paths"] = sources
item["source_linked"] = bool(sources)
plan_items.append(item)
if not sources:
warnings.append({
"type": "plan_missing_source_record",
"path": plan.get("full_path"),
"message": "Plan has no link or metadata pointer to a source record.",
})
for card in cards:
source_plan_path = card_source_plan_path(card)
item = kanban_compact_card(card, view="metadata")
item["source_plan_path"] = source_plan_path
item["plan_linked"] = bool(source_plan_path)
card_items.append(item)
if not source_plan_path:
warnings.append({
"type": "kanban_card_missing_origin_plan",
"path": card.get("full_path"),
"message": "Kanban card has no origin plan link.",
})
result = {
"ok": not warnings,
"status": "ok" if not warnings else "warnings",
"chain": ["source-record", "/plans/<plan-slug>", "/kanban/<column>/<card-slug>"],
"counts": {
"plans": len(plan_items),
"plans_with_source_record": sum(1 for item in plan_items if item.get("source_linked")),
"kanban_cards": len(card_items),
"kanban_cards_with_origin_plan": sum(1 for item in card_items if item.get("plan_linked")),
"warnings": len(warnings),
},
"rules": {
"plan_to_source_record": "A plan should link to the source record it implements, verifies, tracks, or references.",
"kanban_to_plan": "A kanban card created from a plan should link to that plan with relation derived-from.",
},
"warnings": warnings,
}
if include_items:
result["plans"] = plan_items
result["kanban_cards"] = card_items
return result
def plan_to_kanban_card(args):
args = dict(args or {})
plan_path = normalize_node_path(args.get("plan_path") or args.get("path") or "")
if not is_plan_node_path(plan_path):
raise RuntimeError("plan_path must be a /plans/<plan-slug> path")
plan = get_node_by_path({"path": plan_path})
metadata = node_metadata(plan)
title = str(args.get("title") or metadata.get("title") or plan.get("name") or "").strip()
if not title:
raise RuntimeError("title is required")
content_text = args.get("content_text") if args.get("content_text") not in (None, "") else args.get("body")
if content_text in (None, ""):
body = str(plan.get("content_text") or "").strip()
excerpt = body[:4000]
content_text = f"Origin plan: {plan['full_path']}\n\n{excerpt}" if excerpt else f"Origin plan: {plan['full_path']}"
links = add_link_once(args.get("links") or [], plan["full_path"], "derived-from")
card_metadata = dict(args.get("metadata") or {})
card_metadata["source_plan_path"] = plan["full_path"]
card_metadata.setdefault("origin", "plan")
card_args = {
"title": title,
"slug": args.get("slug") or normalize_kanban_slug(title),
"column": args.get("column") or "todo",
"content_text": content_text,
"priority": args.get("priority") or metadata.get("priority") or "normal",
"owner": args.get("owner") or metadata.get("owner"),
"assignee": args.get("assignee") or metadata.get("assignee"),
"due_date": args.get("due_date"),
"links": links,
"tags": unique_preserve_order(list(args.get("tags") or []) + ["kanban", "plan-derived"]),
"metadata": card_metadata,
"upsert": as_bool(args.get("upsert"), False),
"now": args.get("now"),
}
created = create_kanban_card({key: value for key, value in card_args.items() if value not in (None, "")})
return {**created, "source_plan_path": plan["full_path"], "origin_relation": "derived-from"}
def update_kanban_card(args):
args = dict(args or {})
path = args.get("path")
@@ -10330,6 +10505,19 @@ def raw_tools():
"additionalProperties": False,
},
},
{
"name": "nap_lineage_status",
"description": "Report generic source-record -> plan -> kanban-card lineage coverage without assuming a product methodology.",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 25},
"include_archived": {"type": "boolean", "default": False},
"include_items": {"type": "boolean", "default": True},
},
"additionalProperties": False,
},
},
{
"name": "nap_kanban_list",
"description": "List node-based kanban cards under /kanban, grouped and filtered without using ticket tables.",
@@ -10682,6 +10870,31 @@ def raw_tools():
"additionalProperties": False,
},
},
{
"name": "nap_plan_to_kanban",
"description": "Create a node-backed kanban card from any /plans node and preserve the origin plan with a derived-from link.",
"inputSchema": {
"type": "object",
"required": ["plan_path"],
"properties": {
"plan_path": {"type": "string"},
"title": {"type": "string"},
"slug": {"type": "string"},
"column": {"type": "string", "enum": DEFAULT_KANBAN_COLUMNS, "default": "todo"},
"content_text": {"type": "string"},
"body": {"type": "string"},
"priority": {"type": "string", "enum": DEFAULT_KANBAN_PRIORITIES},
"owner": {"type": "string"},
"assignee": {"type": "string"},
"due_date": {"type": "string"},
"links": {"type": "array", "items": {"type": "object"}},
"tags": {"type": "array", "items": {"type": "string"}},
"metadata": {"type": "object"},
"upsert": {"type": "boolean", "default": False},
},
"additionalProperties": False,
},
},
{
"name": "nap_cat",
"description": "Read one node by canonical full_path. Use this before linking if you know the path but need the node id or normalized path.",
@@ -10958,6 +11171,7 @@ TOOL_CATEGORIES = {
"nap_grep",
"nap_find",
"nap_plan_list_active",
"nap_lineage_status",
"nap_kanban_list",
"nap_kanban_get_pending",
"nap_kanban_get_backlog",
@@ -10991,6 +11205,7 @@ TOOL_CATEGORIES = {
"nap_plan_complete",
"nap_plan_supersede",
"nap_plan_cancel",
"nap_plan_to_kanban",
"nap_kanban_create",
"nap_kanban_start",
"nap_kanban_send_review",
@@ -11060,6 +11275,7 @@ READ_ONLY_TOOLS = {
"nap_grep",
"nap_find",
"nap_plan_list_active",
"nap_lineage_status",
"nap_kanban_list",
"nap_kanban_get_pending",
"nap_kanban_get_backlog",
@@ -11110,6 +11326,7 @@ AUTO_LOCK_TOOLS = {
"nap_plan_complete",
"nap_plan_supersede",
"nap_plan_cancel",
"nap_plan_to_kanban",
"nap_kanban_create",
"nap_kanban_start",
"nap_kanban_send_review",
@@ -11165,6 +11382,7 @@ def tool_contract_metadata(name):
"nap_plan_complete",
"nap_plan_supersede",
"nap_plan_cancel",
"nap_plan_to_kanban",
}
return {
"contract_version": CONTRACT_VERSION,
@@ -11407,6 +11625,8 @@ def call_tool_impl(name, args):
return list_project_nodes(args)
if name == "nap_plan_list_active":
return list_active_plans(args)
if name == "nap_lineage_status":
return lineage_status(args)
if name == "nap_kanban_list":
return list_kanban_cards(args)
if name == "nap_kanban_get_pending":
@@ -11492,6 +11712,8 @@ def call_tool_impl(name, args):
return supersede_plan(args)
if name == "nap_plan_cancel":
return cancel_plan(args)
if name == "nap_plan_to_kanban":
return plan_to_kanban_card(args)
if name == "nap_kanban_create":
return create_kanban_card(args)
if name == "nap_kanban_start":
@@ -11694,6 +11916,47 @@ def cli_main(argv):
return
print("Usage: napseer_mcp_server.py project [create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]")
return
if command == "lineage":
subcommand = argv[2] if len(argv) > 2 else "status"
args = argv[3:]
if subcommand in {"status", "ls", "list", "check"}:
print(json.dumps(lineage_status({
"limit": cli_option(args, "--limit", default=None),
"include_archived": cli_flag(args, "--include-archived", "--archived"),
"include_items": not cli_flag(args, "--summary-only", "--no-items"),
}), indent=2))
return
print("Usage: napseer_mcp_server.py lineage [status|check] [--limit N] [--include-archived] [--summary-only]")
return
if command == "plan":
subcommand = argv[2] if len(argv) > 2 else "list"
args = argv[3:]
if subcommand in {"list", "ls", "active"}:
print(json.dumps(list_active_plans({
"limit": cli_option(args, "--limit", default=None),
"view": cli_option(args, "--view", default="summary"),
}), indent=2))
return
if subcommand in {"to-kanban", "card-from", "create-card"}:
plan_path = cli_option(args, "--plan-path", "--path", default=None)
if not plan_path:
plan_path = next((value for value in args if not value.startswith("-")), None)
payload = {
"plan_path": plan_path,
"title": cli_option(args, "--title", default=None),
"slug": cli_option(args, "--slug", default=None),
"column": cli_option(args, "--column", default="todo"),
"priority": cli_option(args, "--priority", default=None),
"owner": cli_option(args, "--owner", default=None),
"assignee": cli_option(args, "--assignee", default=None),
"due_date": cli_option(args, "--due-date", default=None),
"content_text": cli_option(args, "--content", "--content-text", "--body", default=None),
"upsert": cli_flag(args, "--upsert"),
}
print(json.dumps(plan_to_kanban_card(payload), indent=2))
return
print("Usage: napseer_mcp_server.py plan [list|to-kanban <plan-path> [--column todo|doing|review|done|backlog] [--title TEXT] [--priority low|normal|high|urgent] [--upsert]]")
return
if command == "gateway":
subcommand = argv[2] if len(argv) > 2 else "status"
args = argv[3:]
@@ -11938,7 +12201,7 @@ def cli_main(argv):
print("Usage: napseer_mcp_server.py agent [list|workspaces|create <slug>|show <slug>|edit <slug>]")
return
if command in {"help", "-h", "--help"}:
print("Usage: napseer_mcp_server.py [configure|open-ui|ui|update-status|update|auth|project|gateway|feedback|agent]")
print("Usage: napseer_mcp_server.py [configure|open-ui|ui|update-status|update|auth|project|plan|lineage|gateway|feedback|agent]")
return
main()
+9 -1
View File
@@ -646,6 +646,10 @@ def main(argv):
if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args)
if command == "lineage":
return run_wrapper("lineage", args or ["status"])
if command == "plan":
return run_wrapper("plan", args or ["list"])
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
@@ -670,12 +674,16 @@ def main(argv):
)
return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]")
print("Usage: nap [auth|project|plan|lineage|gateway|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption.")
print(" Examples: nap project create, nap project status, nap project encryption status")
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" plan Plan commands: list, to-kanban.")
print(" Example: nap plan to-kanban /plans/example --column todo")
print(" lineage Generic source-record -> plan -> kanban lineage checks.")
print(" Example: nap lineage status")
print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.")
print(" Start auto-selects a local port; use --port to pin one.")
print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat")
@@ -418,6 +418,82 @@ def test_kanban_create_deduplicates_overlapping_titles(mod):
assert writes[0][2]["name"] == "add-mcp-commands-20260510123456-2"
def test_lineage_status_reports_generic_plan_and_card_coverage(mod):
linked_plan = node("/plans/linked", "planned", links=[{"path": "/decisions/source", "relation": "implements"}])
unlinked_plan = node("/plans/unlinked", "planned")
linked_card = node(
"/kanban/todo/linked",
"todo",
metadata={"column": "todo"},
tags=["kanban"],
node_type="kanban_card",
links=[{"path": "/plans/linked", "relation": "derived-from"}],
)
unlinked_card = node(
"/kanban/todo/unlinked",
"todo",
metadata={"column": "todo"},
tags=["kanban"],
node_type="kanban_card",
)
def fake_list(args):
if args.get("folder_path") == "/plans":
return {"items": [linked_plan, unlinked_plan]}
if args.get("q") == "/kanban":
return {"items": [linked_card, unlinked_card, node("/notes/not-kanban", "active")]}
return {"items": []}
mod.list_project_nodes = fake_list
result = mod.lineage_status({})
assert result["status"] == "warnings"
assert result["counts"]["plans"] == 2
assert result["counts"]["plans_with_source_record"] == 1
assert result["counts"]["kanban_cards"] == 2
assert result["counts"]["kanban_cards_with_origin_plan"] == 1
assert [warning["type"] for warning in result["warnings"]] == [
"plan_missing_source_record",
"kanban_card_missing_origin_plan",
]
def test_plan_to_kanban_creates_card_with_origin_plan_link(mod):
writes = []
plan = node(
"/plans/generic-work",
"planned",
metadata={"title": "Generic Work", "priority": "high", "owner": "codex"},
tags=["plan"],
content="Implement a generic record to plan to kanban flow.",
)
mod.get_node_by_path = lambda args, allow_agent=False: plan
mod.list_kanban_cards = lambda args: {"items": []}
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload)) or {
"id": "id-generic-work",
"project_id": "project-1",
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}",
"folder_path": payload.get("folder_path"),
"name": payload.get("name"),
"type": payload.get("type"),
"metadata": payload.get("metadata", {}),
"tags": payload.get("tags", []),
"links": payload.get("links", []),
"content_text": payload.get("content_text", ""),
"updated_at": "2026-05-06T12:00:00Z",
}
mod.index_node = lambda node: None
created = mod.plan_to_kanban_card({"plan_path": "/plans/generic-work", "column": "todo"})
assert created["path"] == "/kanban/todo/generic-work"
assert created["source_plan_path"] == "/plans/generic-work"
assert writes[0][0] == "POST"
assert writes[0][2]["links"] == [{"path": "/plans/generic-work", "relation": "derived-from"}]
assert writes[0][2]["metadata"]["source_plan_path"] == "/plans/generic-work"
assert writes[0][2]["metadata"]["priority"] == "high"
assert writes[0][2]["metadata"]["owner"] == "codex"
def test_plan_lifecycle_dry_run_and_write(mod):
plan = node("/plans/example", "in_progress", tags=["plan"])
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
@@ -476,6 +552,7 @@ def test_tool_registry(mod):
names = {tool["name"] for tool in mod.raw_tools()}
for name in [
"nap_plan_list_active",
"nap_lineage_status",
"nap_kanban_list",
"nap_kanban_get_pending",
"nap_kanban_pick_next",
@@ -484,11 +561,14 @@ def test_tool_registry(mod):
"nap_plan_complete",
"nap_plan_supersede",
"nap_plan_cancel",
"nap_plan_to_kanban",
]:
assert name in names
assert mod.tool_contract_metadata("nap_plan_complete")["dry_run_supported"] is True
assert mod.tool_contract_metadata("nap_plan_list_active")["side_effect"] == "none"
assert mod.tool_contract_metadata("nap_lineage_status")["side_effect"] == "none"
assert mod.tool_contract_metadata("nap_kanban_get_pending")["side_effect"] == "none"
assert mod.tool_contract_metadata("nap_plan_to_kanban")["side_effect"] == "remote-write"
assert mod.tool_contract_metadata("nap_kanban_start")["side_effect"] == "remote-write"
@@ -516,6 +596,10 @@ def run():
mod = load_module()
test_kanban_create_deduplicates_overlapping_titles(mod)
mod = load_module()
test_lineage_status_reports_generic_plan_and_card_coverage(mod)
mod = load_module()
test_plan_to_kanban_creates_card_with_origin_plan_link(mod)
mod = load_module()
test_plan_lifecycle_dry_run_and_write(mod)
mod = load_module()
test_plan_lifecycle_archived_idempotent(mod)