diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..63e86b5 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index cb15fe2..c2b8ea7 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -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") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 646555e..8631fd0 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -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/", "/kanban//"], + "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/ 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 [--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 |show |edit ]") 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() diff --git a/scripts/nap_install.py b/scripts/nap_install.py index cb15fe2..c2b8ea7 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -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") diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index ff00d52..35faa01 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -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)