Add plan lineage CLI helpers
This commit is contained in:
@@ -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.
|
||||||
@@ -646,6 +646,10 @@ def main(argv):
|
|||||||
if args[0] == "bootstrap":
|
if args[0] == "bootstrap":
|
||||||
raise RuntimeError("unknown project command: bootstrap")
|
raise RuntimeError("unknown project command: bootstrap")
|
||||||
return run_wrapper("project", args)
|
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":
|
if command == "agent":
|
||||||
return run_wrapper("agent", args)
|
return run_wrapper("agent", args)
|
||||||
if command == "auth":
|
if command == "auth":
|
||||||
@@ -670,12 +674,16 @@ def main(argv):
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if command in {"help", "-h", "--help"}:
|
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(" auth Auth commands: login-local, status.")
|
||||||
print(" Example: nap auth login-local")
|
print(" Example: nap auth login-local")
|
||||||
print(" project Project commands: create, status, encryption.")
|
print(" project Project commands: create, status, encryption.")
|
||||||
print(" Examples: nap project create, nap project status, nap project encryption status")
|
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(" 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(" 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(" 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")
|
print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat")
|
||||||
|
|||||||
@@ -143,6 +143,21 @@ KANBAN_COLUMN_LIFECYCLE = {
|
|||||||
"review": "review",
|
"review": "review",
|
||||||
"done": "done",
|
"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):
|
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):
|
def update_kanban_card(args):
|
||||||
args = dict(args or {})
|
args = dict(args or {})
|
||||||
path = args.get("path")
|
path = args.get("path")
|
||||||
@@ -10330,6 +10505,19 @@ def raw_tools():
|
|||||||
"additionalProperties": False,
|
"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",
|
"name": "nap_kanban_list",
|
||||||
"description": "List node-based kanban cards under /kanban, grouped and filtered without using ticket tables.",
|
"description": "List node-based kanban cards under /kanban, grouped and filtered without using ticket tables.",
|
||||||
@@ -10682,6 +10870,31 @@ def raw_tools():
|
|||||||
"additionalProperties": False,
|
"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",
|
"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.",
|
"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_grep",
|
||||||
"nap_find",
|
"nap_find",
|
||||||
"nap_plan_list_active",
|
"nap_plan_list_active",
|
||||||
|
"nap_lineage_status",
|
||||||
"nap_kanban_list",
|
"nap_kanban_list",
|
||||||
"nap_kanban_get_pending",
|
"nap_kanban_get_pending",
|
||||||
"nap_kanban_get_backlog",
|
"nap_kanban_get_backlog",
|
||||||
@@ -10991,6 +11205,7 @@ TOOL_CATEGORIES = {
|
|||||||
"nap_plan_complete",
|
"nap_plan_complete",
|
||||||
"nap_plan_supersede",
|
"nap_plan_supersede",
|
||||||
"nap_plan_cancel",
|
"nap_plan_cancel",
|
||||||
|
"nap_plan_to_kanban",
|
||||||
"nap_kanban_create",
|
"nap_kanban_create",
|
||||||
"nap_kanban_start",
|
"nap_kanban_start",
|
||||||
"nap_kanban_send_review",
|
"nap_kanban_send_review",
|
||||||
@@ -11060,6 +11275,7 @@ READ_ONLY_TOOLS = {
|
|||||||
"nap_grep",
|
"nap_grep",
|
||||||
"nap_find",
|
"nap_find",
|
||||||
"nap_plan_list_active",
|
"nap_plan_list_active",
|
||||||
|
"nap_lineage_status",
|
||||||
"nap_kanban_list",
|
"nap_kanban_list",
|
||||||
"nap_kanban_get_pending",
|
"nap_kanban_get_pending",
|
||||||
"nap_kanban_get_backlog",
|
"nap_kanban_get_backlog",
|
||||||
@@ -11110,6 +11326,7 @@ AUTO_LOCK_TOOLS = {
|
|||||||
"nap_plan_complete",
|
"nap_plan_complete",
|
||||||
"nap_plan_supersede",
|
"nap_plan_supersede",
|
||||||
"nap_plan_cancel",
|
"nap_plan_cancel",
|
||||||
|
"nap_plan_to_kanban",
|
||||||
"nap_kanban_create",
|
"nap_kanban_create",
|
||||||
"nap_kanban_start",
|
"nap_kanban_start",
|
||||||
"nap_kanban_send_review",
|
"nap_kanban_send_review",
|
||||||
@@ -11165,6 +11382,7 @@ def tool_contract_metadata(name):
|
|||||||
"nap_plan_complete",
|
"nap_plan_complete",
|
||||||
"nap_plan_supersede",
|
"nap_plan_supersede",
|
||||||
"nap_plan_cancel",
|
"nap_plan_cancel",
|
||||||
|
"nap_plan_to_kanban",
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"contract_version": CONTRACT_VERSION,
|
"contract_version": CONTRACT_VERSION,
|
||||||
@@ -11407,6 +11625,8 @@ def call_tool_impl(name, args):
|
|||||||
return list_project_nodes(args)
|
return list_project_nodes(args)
|
||||||
if name == "nap_plan_list_active":
|
if name == "nap_plan_list_active":
|
||||||
return list_active_plans(args)
|
return list_active_plans(args)
|
||||||
|
if name == "nap_lineage_status":
|
||||||
|
return lineage_status(args)
|
||||||
if name == "nap_kanban_list":
|
if name == "nap_kanban_list":
|
||||||
return list_kanban_cards(args)
|
return list_kanban_cards(args)
|
||||||
if name == "nap_kanban_get_pending":
|
if name == "nap_kanban_get_pending":
|
||||||
@@ -11492,6 +11712,8 @@ def call_tool_impl(name, args):
|
|||||||
return supersede_plan(args)
|
return supersede_plan(args)
|
||||||
if name == "nap_plan_cancel":
|
if name == "nap_plan_cancel":
|
||||||
return cancel_plan(args)
|
return cancel_plan(args)
|
||||||
|
if name == "nap_plan_to_kanban":
|
||||||
|
return plan_to_kanban_card(args)
|
||||||
if name == "nap_kanban_create":
|
if name == "nap_kanban_create":
|
||||||
return create_kanban_card(args)
|
return create_kanban_card(args)
|
||||||
if name == "nap_kanban_start":
|
if name == "nap_kanban_start":
|
||||||
@@ -11694,6 +11916,47 @@ def cli_main(argv):
|
|||||||
return
|
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]]")
|
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
|
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":
|
if command == "gateway":
|
||||||
subcommand = argv[2] if len(argv) > 2 else "status"
|
subcommand = argv[2] if len(argv) > 2 else "status"
|
||||||
args = argv[3:]
|
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>]")
|
print("Usage: napseer_mcp_server.py agent [list|workspaces|create <slug>|show <slug>|edit <slug>]")
|
||||||
return
|
return
|
||||||
if command in {"help", "-h", "--help"}:
|
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
|
return
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -646,6 +646,10 @@ def main(argv):
|
|||||||
if args[0] == "bootstrap":
|
if args[0] == "bootstrap":
|
||||||
raise RuntimeError("unknown project command: bootstrap")
|
raise RuntimeError("unknown project command: bootstrap")
|
||||||
return run_wrapper("project", args)
|
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":
|
if command == "agent":
|
||||||
return run_wrapper("agent", args)
|
return run_wrapper("agent", args)
|
||||||
if command == "auth":
|
if command == "auth":
|
||||||
@@ -670,12 +674,16 @@ def main(argv):
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if command in {"help", "-h", "--help"}:
|
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(" auth Auth commands: login-local, status.")
|
||||||
print(" Example: nap auth login-local")
|
print(" Example: nap auth login-local")
|
||||||
print(" project Project commands: create, status, encryption.")
|
print(" project Project commands: create, status, encryption.")
|
||||||
print(" Examples: nap project create, nap project status, nap project encryption status")
|
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(" 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(" 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(" 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")
|
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"
|
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):
|
def test_plan_lifecycle_dry_run_and_write(mod):
|
||||||
plan = node("/plans/example", "in_progress", tags=["plan"])
|
plan = node("/plans/example", "in_progress", tags=["plan"])
|
||||||
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
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()}
|
names = {tool["name"] for tool in mod.raw_tools()}
|
||||||
for name in [
|
for name in [
|
||||||
"nap_plan_list_active",
|
"nap_plan_list_active",
|
||||||
|
"nap_lineage_status",
|
||||||
"nap_kanban_list",
|
"nap_kanban_list",
|
||||||
"nap_kanban_get_pending",
|
"nap_kanban_get_pending",
|
||||||
"nap_kanban_pick_next",
|
"nap_kanban_pick_next",
|
||||||
@@ -484,11 +561,14 @@ def test_tool_registry(mod):
|
|||||||
"nap_plan_complete",
|
"nap_plan_complete",
|
||||||
"nap_plan_supersede",
|
"nap_plan_supersede",
|
||||||
"nap_plan_cancel",
|
"nap_plan_cancel",
|
||||||
|
"nap_plan_to_kanban",
|
||||||
]:
|
]:
|
||||||
assert name in names
|
assert name in names
|
||||||
assert mod.tool_contract_metadata("nap_plan_complete")["dry_run_supported"] is True
|
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_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_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"
|
assert mod.tool_contract_metadata("nap_kanban_start")["side_effect"] == "remote-write"
|
||||||
|
|
||||||
|
|
||||||
@@ -516,6 +596,10 @@ def run():
|
|||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_kanban_create_deduplicates_overlapping_titles(mod)
|
test_kanban_create_deduplicates_overlapping_titles(mod)
|
||||||
mod = load_module()
|
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)
|
test_plan_lifecycle_dry_run_and_write(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_plan_lifecycle_archived_idempotent(mod)
|
test_plan_lifecycle_archived_idempotent(mod)
|
||||||
|
|||||||
Reference in New Issue
Block a user