ki-manager 2.3.0.dev3__tar.gz → 2.3.1.dev1__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.
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/.gitignore +3 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/CHANGELOG.md +28 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/PKG-INFO +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/pyproject.toml +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/__init__.py +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/analyze_module.py +3 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/audit_coverage.py +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/check_ki_drift.py +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/finalize_ki_scaffolds.py +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/find_unmapped_files.py +2 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/generate_ki_scaffolds.py +1 -1
- ki_manager-2.3.1.dev1/src/ki_manager/scripts/git_diff_secured.py +81 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_search.py +1 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_utils.py +44 -2
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/knowledge_engine.py +5 -4
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/sync_agents_md.py +8 -4
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/server.py +329 -143
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/tools/facade.py +45 -1
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/tools/scaffold.py +7 -6
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/expand-knowledge.md +4 -4
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/sync-knowledge.md +2 -2
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/LICENSE +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/README.md +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/cli.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/add_ki_to_config.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/generate_dir_index.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_dependency_analyzer.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_graph_visualize.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/templates/KI_template.md +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/tools/__init__.py +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/create-adr.md +0 -0
- {ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/scaffold-knowledge.md +0 -0
|
@@ -9,7 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
-
## [2.3.
|
|
12
|
+
## [2.3.1.dev1] — 2026-10-16
|
|
13
|
+
|
|
14
|
+
### Security & Hardening
|
|
15
|
+
- **Path Traversal Protection**: Enforced strict directory boundary containment across all file discovery and inspection tools (`find_unmapped_files`, `analyze_module`, `ki_search`, and custom agent paths) to prevent prefix-based sandbox traversal attacks.
|
|
16
|
+
- **Secured Git Operations (`git_diff_secured`)**: Added hardened diff inspection tool protecting against flag injection (`--output`, etc.), shell command chaining, and repository boundary escapes.
|
|
17
|
+
- **Restricted File Modifications**: Extended write protection to block direct modifications of shell/executable scripts (`.sh`, `.bash`, `.zsh`, `.js`, `.ts`, `.vbs`, `.wsf`) and internal system state files (`doc_config.json`, `ki_config.json`, `config.json`, `doc_state.json`) via direct file editing tools.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Unified Tool Dispatch Schema**: In compact facade mode, dynamically bind `ki_call` and `ki_mutate` tool validation schemas (`enum` and `description`) directly to tool `readOnlyHint` annotations, eliminating dispatch conflicts and preventing model confusion.
|
|
21
|
+
- **Date Format Resilience in `update_last_verified`**: Extended `update_last_verified` to automatically detect and update all supported date notation variants (HTML comment `<!-- last_verified: YYYY-MM-DD -->`, YAML frontmatter `last_verified: "YYYY-MM-DD"`, and Markdown bold `**Last verified**: YYYY-MM-DD`), and automatically insert the verification tag if missing.
|
|
22
|
+
- **Workflow Instructions Alignment**: Synchronized tool arguments and signatures across `/expand-knowledge` and `/sync-knowledge` instructions, ensuring seamless execution in both compact facade and full tool modes.
|
|
23
|
+
- **Daily MCP Log Rotation**: Server diagnostics now rotate daily (`mcp_YYYY-MM-DD.log`) with payload truncation to prevent runaway disk usage during high-frequency sessions.
|
|
24
|
+
|
|
25
|
+
## [2.3.0] — 2026-10-11
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **Unified Cascade Path Resolution (`resolve_know_path`)**: `read_know_file`, `write_know_file`, and `edit_know_file` now share `ki_read`'s smart cascade resolver. AI agents can access and edit Knowledge Items using flat basenames (e.g. `KI_data_persistence.md`) without requiring explicit `knowledge/` directory prefixes.
|
|
29
|
+
- **Smart Directory Placement for New KIs**: `write_know_file` now updates existing files in place across `knowledge/` rather than creating duplicates in the sandbox root, and automatically routes newly created flat KI files (`KI_*.md`, `*.ki.md`) into `<jail>/knowledge/`.
|
|
30
|
+
- **ADR Name & Prefix Tolerance**: `resolve_know_path` and `ki_read` gracefully handle variations with or without `ADR_`/`adr_`/`ADR-` prefixes (e.g., resolving `ADR_002_node_status_queue_and_ui_feedback.md` to `002_node_status_queue_and_ui_feedback.md`) as well as numeric ADR IDs.
|
|
31
|
+
- **Accurate Document Listings**: `get_ki_list_table()` dynamically reconciles registered entries in `doc_config.json` against filesystem reality, outputting exact on-disk filenames in document overviews.
|
|
32
|
+
- **Comprehensive Project Overview**: `ki_instructions(document="knowledge-items")`, prompts, and `ki://knowledge-items.md` now present both Knowledge Items and Architecture Decision Records (ADRs) tables together when ADRs exist.
|
|
33
|
+
|
|
34
|
+
### Highlights from 2.3.0 Development Cycle
|
|
35
|
+
- Compact Facade Mode (`--tool-mode compact`) reducing MCP tools footprint from 34 schemas to 6 tools (`ki_instructions`, `ki_search`, `ki_read`, `ki_tools`, `ki_call`, `ki_mutate`), saving thousands of context tokens.
|
|
36
|
+
- Native BM25 search engine (`ki_search`) with bilingual EN/RU stemming and field-weighted ranking.
|
|
37
|
+
- Token-efficient reader with section filtering and character offset pagination (`ki_read`, `read_know_file`).
|
|
38
|
+
- Batch ADR superseding (`create_adr`) with automatic status transitions, warning banners, git/environment snapshots, and quality sections (`rejected_alternatives`, `invariants`, `verification`).
|
|
39
|
+
- Automated drift analysis (`check_ki_drift`) and Python AST pre-enrichment during scaffolding.
|
|
13
40
|
|
|
14
41
|
### Added
|
|
15
42
|
- `create_adr` batch `supersedes` support: accept a list of previous ADR IDs (`supersedes: ["001", "002"]`) to supersede multiple older records simultaneously; automatically updates deprecated ADR files with warning banners and `Superseded by ADR XXX` statuses, updates `doc_config.json`, and embeds bi-directional cross-links in the generated ADR.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ki-manager
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1.dev1
|
|
4
4
|
Summary: Knowledge Item (KI) management MCP server for AI-assisted development
|
|
5
5
|
Project-URL: Homepage, https://github.com/Laeryid/KI-base
|
|
6
6
|
Project-URL: Repository, https://github.com/Laeryid/KI-base
|
|
@@ -32,6 +32,9 @@ def analyze_path(target_path: str, recursive: bool = False):
|
|
|
32
32
|
|
|
33
33
|
# Resolve absolute path
|
|
34
34
|
abs_target = os.path.normpath(os.path.join(project_root, target_path))
|
|
35
|
+
if not ki_utils.is_subpath(abs_target, project_root):
|
|
36
|
+
print(f"Error: Path '{target_path}' escapes project root.")
|
|
37
|
+
return
|
|
35
38
|
|
|
36
39
|
if not os.path.exists(abs_target):
|
|
37
40
|
print(f"Error: Path {target_path} not found.")
|
|
@@ -65,7 +65,7 @@ def load_ki_files() -> list:
|
|
|
65
65
|
ki_dir = os.path.join(get_knowledge_root(), "knowledge")
|
|
66
66
|
if not os.path.isdir(ki_dir):
|
|
67
67
|
return []
|
|
68
|
-
return [f for f in os.listdir(ki_dir) if
|
|
68
|
+
return [f for f in os.listdir(ki_dir) if ki_utils.is_ki_filename(f)]
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
# ─── Metrics ──────────────────────────────────────────────────────────────────
|
|
@@ -286,7 +286,7 @@ def run_drift_check(
|
|
|
286
286
|
return [check_single_ki(target_name, target_path, doc_config, project_root, knowledge_dir)]
|
|
287
287
|
|
|
288
288
|
results = []
|
|
289
|
-
for f in sorted(knowledge_dir.glob("
|
|
289
|
+
for f in sorted(f for f in knowledge_dir.glob("*.md") if ki_utils.is_ki_filename(f.name)):
|
|
290
290
|
results.append(check_single_ki(f.name, f, doc_config, project_root, knowledge_dir))
|
|
291
291
|
|
|
292
292
|
return results
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/finalize_ki_scaffolds.py
RENAMED
|
@@ -70,7 +70,7 @@ def finalize_scaffolds(dry_run: bool = False) -> None:
|
|
|
70
70
|
skipped_clean = 0
|
|
71
71
|
|
|
72
72
|
for fname in sorted(os.listdir(ki_dir)):
|
|
73
|
-
if not
|
|
73
|
+
if not ki_utils.is_ki_filename(fname):
|
|
74
74
|
continue
|
|
75
75
|
|
|
76
76
|
ki_path = os.path.join(ki_dir, fname)
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/find_unmapped_files.py
RENAMED
|
@@ -29,6 +29,8 @@ def get_unmapped_files(target_path: str = ".") -> List[str]:
|
|
|
29
29
|
|
|
30
30
|
# 2. Scan the target directory
|
|
31
31
|
abs_target = os.path.abspath(os.path.join(project_root, target_path))
|
|
32
|
+
if not ki_utils.is_subpath(abs_target, project_root):
|
|
33
|
+
return []
|
|
32
34
|
if not os.path.exists(abs_target):
|
|
33
35
|
return []
|
|
34
36
|
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/generate_ki_scaffolds.py
RENAMED
|
@@ -514,7 +514,7 @@ def print_scaffold_status() -> None:
|
|
|
514
514
|
enriched_count = 0
|
|
515
515
|
|
|
516
516
|
for fname in sorted(os.listdir(ki_dir)):
|
|
517
|
-
if not
|
|
517
|
+
if not ki_utils.is_ki_filename(fname):
|
|
518
518
|
continue
|
|
519
519
|
|
|
520
520
|
ki_path = os.path.join(ki_dir, fname)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
git_diff_secured.py
|
|
3
|
+
|
|
4
|
+
Safely runs git status and git diff for project files within the project sandbox.
|
|
5
|
+
Prevents argument/flag injection and path traversal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import argparse
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import ki_utils
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def git_diff_secured(paths: List[str] = None) -> str:
|
|
20
|
+
project_root = ki_utils.get_project_root()
|
|
21
|
+
if not project_root or not os.path.exists(project_root):
|
|
22
|
+
return "Error: No active project context."
|
|
23
|
+
|
|
24
|
+
safe_paths = []
|
|
25
|
+
if paths:
|
|
26
|
+
for p in paths:
|
|
27
|
+
clean_p = p.strip()
|
|
28
|
+
if not clean_p:
|
|
29
|
+
continue
|
|
30
|
+
if clean_p.startswith("-") or any(char in clean_p for char in [";", "|", "&", ">", "<", "`"]):
|
|
31
|
+
return f"Error: Suspicious argument or path '{clean_p}'."
|
|
32
|
+
abs_p = os.path.abspath(os.path.join(project_root, clean_p))
|
|
33
|
+
if not ki_utils.is_subpath(abs_p, project_root):
|
|
34
|
+
return f"Access Denied: Path '{clean_p}' escapes project root."
|
|
35
|
+
safe_paths.append(os.path.relpath(abs_p, project_root).replace("\\", "/"))
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
if safe_paths:
|
|
39
|
+
stat_cmd = ["git", "status", "--short", "--"] + safe_paths
|
|
40
|
+
diff_cmd = ["git", "diff", "HEAD", "--"] + safe_paths
|
|
41
|
+
else:
|
|
42
|
+
stat_cmd = ["git", "status", "--short"]
|
|
43
|
+
diff_cmd = ["git", "diff", "HEAD"]
|
|
44
|
+
|
|
45
|
+
stat_res = subprocess.run(
|
|
46
|
+
stat_cmd, cwd=project_root, capture_output=True, text=True, encoding="utf-8"
|
|
47
|
+
)
|
|
48
|
+
diff_res = subprocess.run(
|
|
49
|
+
diff_cmd, cwd=project_root, capture_output=True, text=True, encoding="utf-8"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
output = []
|
|
53
|
+
if stat_res.stdout.strip():
|
|
54
|
+
output.append("### Git Status:\n" + stat_res.stdout.strip())
|
|
55
|
+
if diff_res.stdout.strip():
|
|
56
|
+
output.append("### Git Diff:\n" + diff_res.stdout.strip())
|
|
57
|
+
if not output:
|
|
58
|
+
output.append("No changes detected in git.")
|
|
59
|
+
|
|
60
|
+
return "\n\n".join(output)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return f"Git execution error: {str(e)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
try:
|
|
68
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
parser = argparse.ArgumentParser(description="Secured git diff for project files.")
|
|
73
|
+
parser.add_argument("paths", nargs=argparse.REMAINDER, help="File or directory paths relative to project root")
|
|
74
|
+
args = parser.parse_args()
|
|
75
|
+
|
|
76
|
+
result = git_diff_secured(args.paths)
|
|
77
|
+
print(result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -218,7 +218,7 @@ def search_knowledge(
|
|
|
218
218
|
return {"error": "No project root found. Run ki_init_project or specify workspace.", "results": []}
|
|
219
219
|
|
|
220
220
|
knowledge_root = ki_utils.get_knowledge_root()
|
|
221
|
-
if not knowledge_root or not os.path.exists(knowledge_root) or not
|
|
221
|
+
if not knowledge_root or not os.path.exists(knowledge_root) or not ki_utils.is_subpath(knowledge_root, project_root):
|
|
222
222
|
for cand in [
|
|
223
223
|
os.path.join(project_root, ".ki-base", "knowledge"),
|
|
224
224
|
os.path.join(project_root, "knowledge"),
|
|
@@ -58,6 +58,28 @@ def normalize_path(path_str: str, make_absolute: bool = True) -> str:
|
|
|
58
58
|
return path_str
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def is_subpath(target: str, root: str) -> bool:
|
|
62
|
+
"""Checks whether target path is strictly within or equal to root directory."""
|
|
63
|
+
if not target or not root:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
norm_t = os.path.normcase(os.path.abspath(target))
|
|
67
|
+
norm_r = os.path.normcase(os.path.abspath(root))
|
|
68
|
+
return os.path.commonpath([norm_t, norm_r]) == norm_r
|
|
69
|
+
except (ValueError, OSError):
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_ki_filename(name: str, include_overview: bool = False) -> bool:
|
|
74
|
+
"""Checks if a file name matches Knowledge Item convention (KI_*.md or *.ki.md)."""
|
|
75
|
+
if not name or not name.endswith(".md"):
|
|
76
|
+
return False
|
|
77
|
+
base = os.path.basename(name)
|
|
78
|
+
if not include_overview and base in ("_OVERVIEW.ki.md", "_OVERVIEW.md"):
|
|
79
|
+
return False
|
|
80
|
+
return base.startswith("KI_") or base.endswith(".ki.md")
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
# ─── Registry Management ─────────────────────────────────────────────────────
|
|
62
84
|
|
|
63
85
|
def get_registry_path() -> Path:
|
|
@@ -139,7 +161,7 @@ def find_project_by_cwd(cwd=None) -> dict:
|
|
|
139
161
|
|
|
140
162
|
for proj_root, data in registry["projects"].items():
|
|
141
163
|
norm_proj = normalize_path(proj_root)
|
|
142
|
-
if
|
|
164
|
+
if is_subpath(cwd, norm_proj):
|
|
143
165
|
if len(norm_proj) > max_len:
|
|
144
166
|
max_len = len(norm_proj)
|
|
145
167
|
best_match = data
|
|
@@ -409,10 +431,30 @@ def get_ki_list_table() -> str:
|
|
|
409
431
|
items = doc_config.get("knowledge_items", {})
|
|
410
432
|
if not items:
|
|
411
433
|
return "No Knowledge Items registered yet."
|
|
434
|
+
jail = get_knowledge_root()
|
|
435
|
+
proj = get_project_root()
|
|
436
|
+
dec_dirs = get_decisions_dirs(proj, jail)
|
|
437
|
+
|
|
412
438
|
rows = ["| File | Topic / Summary |", "|------|-----------------|"]
|
|
413
439
|
for name, info in sorted(items.items()):
|
|
414
440
|
summary = info.get("summary", info.get("description", "—"))
|
|
415
|
-
|
|
441
|
+
display_name = name
|
|
442
|
+
if "path" in info and info["path"]:
|
|
443
|
+
display_name = info["path"]
|
|
444
|
+
elif jail or proj:
|
|
445
|
+
# Check if name exists literally
|
|
446
|
+
exists_literal = False
|
|
447
|
+
for root_cand in ([jail] if jail else []) + dec_dirs:
|
|
448
|
+
if os.path.exists(os.path.join(root_cand, name)) or os.path.exists(os.path.join(root_cand, "knowledge", name)):
|
|
449
|
+
exists_literal = True
|
|
450
|
+
break
|
|
451
|
+
if not exists_literal and re.match(r"^(?:ADR|adr)[_\-]", name):
|
|
452
|
+
stripped = re.sub(r"^(?:ADR|adr)[_\-]", "", name)
|
|
453
|
+
for d in dec_dirs:
|
|
454
|
+
if os.path.exists(os.path.join(d, stripped)):
|
|
455
|
+
display_name = stripped
|
|
456
|
+
break
|
|
457
|
+
rows.append(f"| `{display_name}` | {summary} |")
|
|
416
458
|
return "\n".join(rows)
|
|
417
459
|
|
|
418
460
|
|
|
@@ -201,11 +201,11 @@ class KnowledgeEngine:
|
|
|
201
201
|
if not knowledge_dir.exists():
|
|
202
202
|
return "Knowledge directory not found"
|
|
203
203
|
|
|
204
|
-
path_pattern = re.compile(r"`(
|
|
204
|
+
path_pattern = re.compile(r"`([a-zA-Z0-9_\-\.]+/[a-zA-Z0-9_\-\./]+)`")
|
|
205
205
|
|
|
206
206
|
updated_count = 0
|
|
207
207
|
for filename in os.listdir(knowledge_dir):
|
|
208
|
-
if not
|
|
208
|
+
if not ki_utils.is_ki_filename(filename, include_overview=True):
|
|
209
209
|
continue
|
|
210
210
|
|
|
211
211
|
ki_path = knowledge_dir / filename
|
|
@@ -215,8 +215,9 @@ class KnowledgeEngine:
|
|
|
215
215
|
mentioned = path_pattern.findall(content)
|
|
216
216
|
valid_paths = set()
|
|
217
217
|
for p in mentioned:
|
|
218
|
-
p = p.rstrip("
|
|
219
|
-
|
|
218
|
+
p = p.rstrip(".,;:)")
|
|
219
|
+
target_file = (self.project_root / p).resolve()
|
|
220
|
+
if ki_utils.is_subpath(str(target_file), str(self.project_root)) and target_file.exists():
|
|
220
221
|
valid_paths.add(p.replace("\\", "/"))
|
|
221
222
|
|
|
222
223
|
if filename not in ki_items:
|
|
@@ -16,12 +16,16 @@ import ki_utils
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def resolve_agents_path(agents_path: str = None) -> str:
|
|
19
|
-
"""Finds the AGENTS.md file in the project."""
|
|
20
19
|
if agents_path:
|
|
21
20
|
p = ki_utils.normalize_path(agents_path)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
pr = ki_utils.get_project_root()
|
|
22
|
+
kr = ki_utils.get_knowledge_root()
|
|
23
|
+
allowed = [pr] if pr else []
|
|
24
|
+
if kr and kr not in allowed:
|
|
25
|
+
allowed.append(kr)
|
|
26
|
+
if allowed and not any(ki_utils.is_subpath(p, r) for r in allowed):
|
|
27
|
+
return ""
|
|
28
|
+
return p
|
|
25
29
|
|
|
26
30
|
pr = ki_utils.get_project_root()
|
|
27
31
|
kr = ki_utils.get_knowledge_root()
|
|
@@ -13,6 +13,7 @@ Usage:
|
|
|
13
13
|
import sys
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import re
|
|
16
17
|
import subprocess
|
|
17
18
|
import importlib.metadata
|
|
18
19
|
from pathlib import Path
|
|
@@ -46,7 +47,11 @@ _LOG_DIR = Path.home() / ".ki_base" / "logs"
|
|
|
46
47
|
def safe_log(msg: str):
|
|
47
48
|
try:
|
|
48
49
|
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
|
|
50
|
+
import datetime
|
|
51
|
+
today_str = datetime.date.today().isoformat()
|
|
52
|
+
log_file = _LOG_DIR / f"mcp_{today_str}.log"
|
|
53
|
+
if len(msg) > 2000:
|
|
54
|
+
msg = msg[:1997] + "..."
|
|
50
55
|
with open(log_file, "a", encoding="utf-8") as lf:
|
|
51
56
|
lf.write(msg + "\n")
|
|
52
57
|
except Exception:
|
|
@@ -114,15 +119,20 @@ def get_adr_list(project_root: str, jail: str) -> str:
|
|
|
114
119
|
|
|
115
120
|
# ─── Security ─────────────────────────────────────────────────────────────────
|
|
116
121
|
|
|
117
|
-
_FORBIDDEN_WRITE_EXT = {
|
|
118
|
-
|
|
122
|
+
_FORBIDDEN_WRITE_EXT = {
|
|
123
|
+
".py", ".pyc", ".bat", ".ps1", ".sh", ".exe", ".cmd", ".dll",
|
|
124
|
+
".js", ".mjs", ".cjs", ".ts", ".vbs", ".wsf", ".bash", ".zsh"
|
|
125
|
+
}
|
|
126
|
+
_FORBIDDEN_WRITE_FILES = {
|
|
127
|
+
"doc_config.json", "ki_config.json", "config.json", "doc_state.json"
|
|
128
|
+
} # can only be modified via dedicated tools
|
|
119
129
|
|
|
120
130
|
NO_WORKSPACE_ERROR = {
|
|
121
131
|
"isError": True,
|
|
122
132
|
"content": [{
|
|
123
133
|
"type": "text",
|
|
124
134
|
"text": (
|
|
125
|
-
"
|
|
135
|
+
"✖ No active workspace detected.\n\n"
|
|
126
136
|
"To fix, call ki_status with explicit path:\n"
|
|
127
137
|
" ki_status({\"path\": \"/absolute/path/to/project\"})\n\n"
|
|
128
138
|
"Or ensure the MCP client passes workspaceUri in _meta.io.modelcontextprotocol/clientInfo."
|
|
@@ -135,7 +145,7 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
|
|
|
135
145
|
jail = get_jail_dir()
|
|
136
146
|
if not jail:
|
|
137
147
|
raise PermissionError(
|
|
138
|
-
"
|
|
148
|
+
"✖ No active workspace detected. "
|
|
139
149
|
"Call ki_status({\"path\": \"/absolute/path/to/project\"}) to set it."
|
|
140
150
|
)
|
|
141
151
|
|
|
@@ -147,7 +157,7 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
|
|
|
147
157
|
else:
|
|
148
158
|
target = os.path.abspath(normalized)
|
|
149
159
|
|
|
150
|
-
if not
|
|
160
|
+
if not ki_utils.is_subpath(target, jail):
|
|
151
161
|
raise PermissionError("Access Denied: jail breach detected.")
|
|
152
162
|
|
|
153
163
|
if is_write:
|
|
@@ -155,11 +165,180 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
|
|
|
155
165
|
if ext in _FORBIDDEN_WRITE_EXT:
|
|
156
166
|
raise PermissionError(f"Access Denied: modifying {ext} files is forbidden.")
|
|
157
167
|
if os.path.basename(target).lower() in _FORBIDDEN_WRITE_FILES:
|
|
158
|
-
raise PermissionError(
|
|
168
|
+
raise PermissionError("Access Denied: use dedicated tools to modify this file.")
|
|
159
169
|
|
|
160
170
|
return target
|
|
161
171
|
|
|
162
172
|
|
|
173
|
+
def resolve_know_path(rel_path: str, is_write: bool = False, must_exist: bool = False) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Resolves a relative knowledge/ADR path using smart cascading, security validation,
|
|
176
|
+
and tolerance for prefixes like 'ADR_'.
|
|
177
|
+
|
|
178
|
+
If must_exist is True: raises FileNotFoundError if not found.
|
|
179
|
+
If must_exist is False and file doesn't exist: returns the target creation path
|
|
180
|
+
(defaulting flat KI files to <jail>/knowledge/<norm>).
|
|
181
|
+
"""
|
|
182
|
+
jail = get_jail_dir()
|
|
183
|
+
if not jail:
|
|
184
|
+
raise PermissionError(
|
|
185
|
+
"✖ No active workspace detected. "
|
|
186
|
+
"Call ki_status({\"path\": \"/absolute/path/to/project\"}) to set it."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
norm = ki_utils.normalize_path(rel_path, make_absolute=False)
|
|
190
|
+
if not norm:
|
|
191
|
+
raise ValueError("rel_path cannot be empty.")
|
|
192
|
+
|
|
193
|
+
if not os.path.isabs(norm) and norm.startswith(".."):
|
|
194
|
+
raise PermissionError(f"Access Denied: path '{rel_path}' escapes sandbox.")
|
|
195
|
+
|
|
196
|
+
project_root = get_project_root()
|
|
197
|
+
base_name = os.path.basename(norm)
|
|
198
|
+
dir_name = os.path.dirname(norm)
|
|
199
|
+
|
|
200
|
+
# Name variants (tolerance for ADR_ / adr_ prefixes)
|
|
201
|
+
name_variants = [base_name]
|
|
202
|
+
m_adr = re.match(r"^(?:ADR|adr)[_\-](.*)", base_name)
|
|
203
|
+
if m_adr:
|
|
204
|
+
name_variants.append(m_adr.group(1))
|
|
205
|
+
elif re.match(r"^\d+", base_name):
|
|
206
|
+
name_variants.append(f"ADR_{base_name}")
|
|
207
|
+
name_variants.append(f"adr_{base_name}")
|
|
208
|
+
|
|
209
|
+
target = None
|
|
210
|
+
|
|
211
|
+
# 1. Direct path inside jail
|
|
212
|
+
try:
|
|
213
|
+
cand = validate_path(norm)
|
|
214
|
+
if os.path.exists(cand) and os.path.isfile(cand):
|
|
215
|
+
target = cand
|
|
216
|
+
except Exception:
|
|
217
|
+
target = None
|
|
218
|
+
|
|
219
|
+
# 1b. If base_name had ADR_ prefix, try direct path with stripped name in same directory inside jail
|
|
220
|
+
if not target and m_adr and dir_name:
|
|
221
|
+
try:
|
|
222
|
+
cand = validate_path(os.path.join(dir_name, m_adr.group(1)))
|
|
223
|
+
if os.path.exists(cand) and os.path.isfile(cand):
|
|
224
|
+
target = cand
|
|
225
|
+
except Exception:
|
|
226
|
+
target = None
|
|
227
|
+
|
|
228
|
+
# 2. Inside jail/knowledge/ (standard structure: <jail>/knowledge/<file>)
|
|
229
|
+
if not target and jail:
|
|
230
|
+
for nv in name_variants:
|
|
231
|
+
sub = os.path.join(dir_name, nv) if dir_name else nv
|
|
232
|
+
try:
|
|
233
|
+
cand = validate_path(os.path.join("knowledge", sub))
|
|
234
|
+
if os.path.exists(cand) and os.path.isfile(cand):
|
|
235
|
+
target = cand
|
|
236
|
+
break
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# 3. If rel_path starts with the knowledge_root folder name itself (e.g. '.ki-base/...' or 'knowledge/...'),
|
|
241
|
+
# strip prefix and resolve inside jail
|
|
242
|
+
if not target and jail and project_root:
|
|
243
|
+
rel_to_proj = os.path.relpath(jail, project_root).replace("\\", "/")
|
|
244
|
+
if norm.startswith(rel_to_proj + "/"):
|
|
245
|
+
sub_path = norm[len(rel_to_proj) + 1:]
|
|
246
|
+
try:
|
|
247
|
+
cand = validate_path(sub_path)
|
|
248
|
+
if os.path.exists(cand) and os.path.isfile(cand):
|
|
249
|
+
target = cand
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
if not target:
|
|
253
|
+
try:
|
|
254
|
+
cand = validate_path(os.path.join("knowledge", sub_path))
|
|
255
|
+
if os.path.exists(cand) and os.path.isfile(cand):
|
|
256
|
+
target = cand
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
# 4. Decisions directories (ADR resolution)
|
|
261
|
+
if not target and project_root:
|
|
262
|
+
candidates = ki_utils.get_decisions_dirs(project_root, jail)
|
|
263
|
+
for d in candidates:
|
|
264
|
+
if not os.path.exists(d):
|
|
265
|
+
continue
|
|
266
|
+
# Try by name variants
|
|
267
|
+
for nv in name_variants:
|
|
268
|
+
cand_p = os.path.abspath(os.path.join(d, nv))
|
|
269
|
+
if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
270
|
+
target = cand_p
|
|
271
|
+
break
|
|
272
|
+
if target:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
# If still not found, match by ADR ID prefix (e.g. '002' or '2')
|
|
276
|
+
m_id = re.match(r"^(?:ADR[_\-]?)?(\d+)", base_name, re.IGNORECASE)
|
|
277
|
+
if m_id:
|
|
278
|
+
target_id = m_id.group(1)
|
|
279
|
+
for f in os.listdir(d):
|
|
280
|
+
if f.endswith(".md"):
|
|
281
|
+
m_f = re.match(r"^(?:ADR[_\-]?)?(\d+)", f, re.IGNORECASE)
|
|
282
|
+
if m_f and (m_f.group(1) == target_id or m_f.group(1).lstrip("0") == target_id.lstrip("0")):
|
|
283
|
+
target = os.path.abspath(os.path.join(d, f))
|
|
284
|
+
break
|
|
285
|
+
if target:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# Also check relative to project root directly if within decisions or jail
|
|
289
|
+
if not target:
|
|
290
|
+
cand_p = os.path.abspath(os.path.join(project_root, norm))
|
|
291
|
+
if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
292
|
+
valid_prefixes = candidates + ([jail] if jail else [])
|
|
293
|
+
for vp in valid_prefixes:
|
|
294
|
+
if os.path.normcase(cand_p).startswith(os.path.normcase(vp)):
|
|
295
|
+
target = cand_p
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# 5. Fallback: Search by basename variants in jail and its subfolders
|
|
299
|
+
if not target and jail and os.path.exists(jail):
|
|
300
|
+
for root, _, files in os.walk(jail):
|
|
301
|
+
for nv in name_variants:
|
|
302
|
+
if nv in files:
|
|
303
|
+
cand_p = os.path.abspath(os.path.join(root, nv))
|
|
304
|
+
if cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
305
|
+
target = cand_p
|
|
306
|
+
break
|
|
307
|
+
if target:
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
# Security check on resolved existing target
|
|
311
|
+
if target:
|
|
312
|
+
valid_roots = [jail]
|
|
313
|
+
if project_root:
|
|
314
|
+
valid_roots.extend(ki_utils.get_decisions_dirs(project_root, jail))
|
|
315
|
+
is_safe = any(ki_utils.is_subpath(target, vr) for vr in valid_roots if vr)
|
|
316
|
+
if not is_safe:
|
|
317
|
+
raise PermissionError("Access Denied: jail breach detected.")
|
|
318
|
+
|
|
319
|
+
if is_write:
|
|
320
|
+
ext = os.path.splitext(target)[1].lower()
|
|
321
|
+
if ext in _FORBIDDEN_WRITE_EXT:
|
|
322
|
+
raise PermissionError(f"Access Denied: modifying {ext} files is forbidden.")
|
|
323
|
+
if os.path.basename(target).lower() in _FORBIDDEN_WRITE_FILES:
|
|
324
|
+
raise PermissionError("Access Denied: use dedicated tools to modify this file.")
|
|
325
|
+
|
|
326
|
+
return target
|
|
327
|
+
|
|
328
|
+
# Target not found
|
|
329
|
+
if must_exist:
|
|
330
|
+
raise FileNotFoundError(f"Knowledge file '{rel_path}' not found.")
|
|
331
|
+
|
|
332
|
+
# Target does not exist, but we are resolving path for creation (e.g. write_know_file)
|
|
333
|
+
# If path is flat (no dir specified) and jail has knowledge folder, default to knowledge/
|
|
334
|
+
if not dir_name:
|
|
335
|
+
know_dir = os.path.join(jail, "knowledge")
|
|
336
|
+
if os.path.exists(know_dir) or base_name.startswith("KI_") or base_name.endswith(".ki.md"):
|
|
337
|
+
return validate_path(os.path.join("knowledge", base_name), is_write=is_write)
|
|
338
|
+
|
|
339
|
+
return validate_path(norm, is_write=is_write)
|
|
340
|
+
|
|
341
|
+
|
|
163
342
|
# ─── Script runner ────────────────────────────────────────────────────────────
|
|
164
343
|
|
|
165
344
|
def run_script(script_name: str, args: List[str] = None) -> dict:
|
|
@@ -344,7 +523,7 @@ MCP_TOOLS = [
|
|
|
344
523
|
"inputSchema": {"type": "object", "properties": {}},
|
|
345
524
|
"annotations": {
|
|
346
525
|
"title": "Generate Dir Index",
|
|
347
|
-
"readOnlyHint":
|
|
526
|
+
"readOnlyHint": True,
|
|
348
527
|
"destructiveHint": False,
|
|
349
528
|
"idempotentHint": True,
|
|
350
529
|
},
|
|
@@ -361,7 +540,7 @@ MCP_TOOLS = [
|
|
|
361
540
|
},
|
|
362
541
|
"annotations": {
|
|
363
542
|
"title": "Analyze Dependencies",
|
|
364
|
-
"readOnlyHint":
|
|
543
|
+
"readOnlyHint": True,
|
|
365
544
|
"destructiveHint": False,
|
|
366
545
|
"idempotentHint": True,
|
|
367
546
|
},
|
|
@@ -372,7 +551,7 @@ MCP_TOOLS = [
|
|
|
372
551
|
"inputSchema": {"type": "object", "properties": {}},
|
|
373
552
|
"annotations": {
|
|
374
553
|
"title": "Analyze All Dependencies",
|
|
375
|
-
"readOnlyHint":
|
|
554
|
+
"readOnlyHint": True,
|
|
376
555
|
"destructiveHint": False,
|
|
377
556
|
"idempotentHint": True,
|
|
378
557
|
},
|
|
@@ -864,6 +1043,9 @@ MCP_TOOLS = [
|
|
|
864
1043
|
facade.KI_READ_TOOL,
|
|
865
1044
|
]
|
|
866
1045
|
|
|
1046
|
+
# Dynamically bind ki_call and ki_mutate schemas to readOnlyHint annotations
|
|
1047
|
+
facade.sync_dispatch_schemas(MCP_TOOLS)
|
|
1048
|
+
|
|
867
1049
|
_INSTRUCTIONS_TOOL = next(t for t in MCP_TOOLS if t["name"] == "ki_instructions")
|
|
868
1050
|
COMPACT_TOOLS = [
|
|
869
1051
|
_INSTRUCTIONS_TOOL,
|
|
@@ -1455,82 +1637,12 @@ def tool_read_file(args: dict) -> Any:
|
|
|
1455
1637
|
if not rel_path:
|
|
1456
1638
|
return "Error: 'rel_path' is required."
|
|
1457
1639
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
target = None
|
|
1462
|
-
norm = ki_utils.normalize_path(rel_path, make_absolute=False)
|
|
1463
|
-
base_name = os.path.basename(norm)
|
|
1464
|
-
|
|
1465
|
-
# 1. Try resolving within jail (knowledge root from config, default .ki-base)
|
|
1466
|
-
if jail:
|
|
1467
|
-
# 1a. Direct path inside jail
|
|
1468
|
-
try:
|
|
1469
|
-
cand = validate_path(norm)
|
|
1470
|
-
if os.path.exists(cand) and os.path.isfile(cand):
|
|
1471
|
-
target = cand
|
|
1472
|
-
except Exception:
|
|
1473
|
-
target = None
|
|
1474
|
-
|
|
1475
|
-
# 1b. Try inside jail/knowledge/ (standard structure: <jail>/knowledge/<file>)
|
|
1476
|
-
if not target:
|
|
1477
|
-
try:
|
|
1478
|
-
cand = validate_path(os.path.join("knowledge", norm))
|
|
1479
|
-
if os.path.exists(cand) and os.path.isfile(cand):
|
|
1480
|
-
target = cand
|
|
1481
|
-
except Exception:
|
|
1482
|
-
target = None
|
|
1483
|
-
|
|
1484
|
-
# 1c. If rel_path starts with the knowledge_root folder name itself (e.g. '.ki-base/...' or 'knowledge/...'),
|
|
1485
|
-
# strip the prefix and resolve inside jail
|
|
1486
|
-
if not target and project_root:
|
|
1487
|
-
rel_to_proj = os.path.relpath(jail, project_root).replace("\\", "/")
|
|
1488
|
-
if norm.startswith(rel_to_proj + "/"):
|
|
1489
|
-
sub_path = norm[len(rel_to_proj) + 1:]
|
|
1490
|
-
try:
|
|
1491
|
-
cand = validate_path(sub_path)
|
|
1492
|
-
if os.path.exists(cand) and os.path.isfile(cand):
|
|
1493
|
-
target = cand
|
|
1494
|
-
except Exception:
|
|
1495
|
-
pass
|
|
1496
|
-
if not target:
|
|
1497
|
-
try:
|
|
1498
|
-
cand = validate_path(os.path.join("knowledge", sub_path))
|
|
1499
|
-
if os.path.exists(cand) and os.path.isfile(cand):
|
|
1500
|
-
target = cand
|
|
1501
|
-
except Exception:
|
|
1502
|
-
pass
|
|
1503
|
-
|
|
1504
|
-
# 2. Try resolving ADR or path relative to project_root
|
|
1505
|
-
if not target and project_root:
|
|
1506
|
-
candidates = ki_utils.get_decisions_dirs(project_root, jail)
|
|
1507
|
-
# Check by basename in decisions dirs
|
|
1508
|
-
for d in candidates:
|
|
1509
|
-
cand_p = os.path.abspath(os.path.join(d, base_name))
|
|
1510
|
-
if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
1511
|
-
target = cand_p
|
|
1512
|
-
break
|
|
1513
|
-
if not target:
|
|
1514
|
-
# Check relative to project root, ensuring it is within decisions or jail
|
|
1515
|
-
cand_p = os.path.abspath(os.path.join(project_root, norm))
|
|
1516
|
-
if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
1517
|
-
valid_prefixes = candidates + ([jail] if jail else [])
|
|
1518
|
-
for vp in valid_prefixes:
|
|
1519
|
-
if os.path.normcase(cand_p).startswith(os.path.normcase(vp)):
|
|
1520
|
-
target = cand_p
|
|
1521
|
-
break
|
|
1522
|
-
|
|
1523
|
-
# 3. Fallback: Search by basename in jail (knowledge root) and its subfolders
|
|
1524
|
-
if not target and jail and os.path.exists(jail):
|
|
1525
|
-
for root, _, files in os.walk(jail):
|
|
1526
|
-
if base_name in files:
|
|
1527
|
-
cand_p = os.path.abspath(os.path.join(root, base_name))
|
|
1528
|
-
if cand_p.endswith(".md") and os.path.isfile(cand_p):
|
|
1529
|
-
target = cand_p
|
|
1530
|
-
break
|
|
1531
|
-
|
|
1532
|
-
if not target or not os.path.exists(target):
|
|
1640
|
+
try:
|
|
1641
|
+
target = resolve_know_path(rel_path, must_exist=True)
|
|
1642
|
+
except FileNotFoundError:
|
|
1533
1643
|
return f"File not found: '{rel_path}'."
|
|
1644
|
+
except Exception as e:
|
|
1645
|
+
return f"Error: {e}"
|
|
1534
1646
|
|
|
1535
1647
|
try:
|
|
1536
1648
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
@@ -1584,10 +1696,14 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1584
1696
|
if doc == "overview":
|
|
1585
1697
|
return facade.render_instruction(GLOBAL_INSTRUCTIONS, CURRENT_TOOL_MODE, know_name)
|
|
1586
1698
|
elif doc == "knowledge-items":
|
|
1587
|
-
|
|
1699
|
+
ki_table = ki_utils.get_ki_list_table()
|
|
1700
|
+
adr_table = ki_utils.get_adr_table()
|
|
1701
|
+
if adr_table and not adr_table.startswith("No ADRs found"):
|
|
1702
|
+
return f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
|
|
1703
|
+
return f"Knowledge Items for this project:\n\n{ki_table}"
|
|
1588
1704
|
else:
|
|
1589
|
-
wf_path = _WORKFLOWS_DIR / f"{doc}.md"
|
|
1590
|
-
if wf_path.exists():
|
|
1705
|
+
wf_path = (_WORKFLOWS_DIR / f"{doc}.md").resolve()
|
|
1706
|
+
if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
|
|
1591
1707
|
with open(wf_path, "r", encoding="utf-8") as f:
|
|
1592
1708
|
raw = f.read()
|
|
1593
1709
|
return facade.render_instruction(raw, CURRENT_TOOL_MODE, know_name)
|
|
@@ -1604,15 +1720,19 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1604
1720
|
from scaffold import init_project
|
|
1605
1721
|
return init_project(args)
|
|
1606
1722
|
|
|
1607
|
-
#
|
|
1723
|
+
# ─── Registry ───
|
|
1608
1724
|
if name == "ki_migrate_project":
|
|
1725
|
+
sys.path.insert(0, str(_PACKAGE_DIR / "tools"))
|
|
1726
|
+
import scaffold
|
|
1609
1727
|
return scaffold.migrate_project(str(get_project_root()))
|
|
1610
1728
|
if name == "ki_register_project":
|
|
1611
1729
|
config_path = args.get("config_path", "")
|
|
1730
|
+
workspace = args.get("workspace")
|
|
1731
|
+
inline_config = args.get("inline_config")
|
|
1612
1732
|
# Accept directory too
|
|
1613
1733
|
if config_path and os.path.isdir(ki_utils.normalize_path(config_path)):
|
|
1614
1734
|
config_path = os.path.join(ki_utils.normalize_path(config_path), "ki_config.json")
|
|
1615
|
-
ok, msg = ki_utils.register_project(config_path)
|
|
1735
|
+
ok, msg = ki_utils.register_project(config_path=config_path, workspace=workspace, inline_config=inline_config)
|
|
1616
1736
|
return msg
|
|
1617
1737
|
if name == "ki_list_projects":
|
|
1618
1738
|
reg = ki_utils.load_registry()
|
|
@@ -1628,37 +1748,85 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1628
1748
|
if name == "ki_prune_registry":
|
|
1629
1749
|
reg = ki_utils.load_registry()
|
|
1630
1750
|
before = len(reg["projects"])
|
|
1631
|
-
|
|
1632
|
-
|
|
1751
|
+
surviving = {}
|
|
1752
|
+
for k, v in reg["projects"].items():
|
|
1753
|
+
cfg_p = v.get("config_path")
|
|
1754
|
+
if cfg_p and os.path.exists(cfg_p):
|
|
1755
|
+
surviving[k] = v
|
|
1756
|
+
elif not cfg_p and os.path.exists(k):
|
|
1757
|
+
surviving[k] = v
|
|
1758
|
+
reg["projects"] = surviving
|
|
1633
1759
|
ki_utils.save_registry(reg)
|
|
1634
1760
|
return f"Pruned {before - len(reg['projects'])} stale project(s)."
|
|
1635
1761
|
|
|
1636
|
-
#
|
|
1762
|
+
# ─── Coverage / Analysis ───
|
|
1637
1763
|
if name == "audit_coverage":
|
|
1638
1764
|
return run_script("audit_coverage.py")
|
|
1639
1765
|
|
|
1640
1766
|
if name == "generate_dir_index":
|
|
1641
1767
|
return run_script("generate_dir_index.py")
|
|
1642
1768
|
if name == "update_last_verified":
|
|
1643
|
-
import datetime
|
|
1769
|
+
import datetime
|
|
1644
1770
|
jail = get_jail_dir()
|
|
1645
|
-
if not jail:
|
|
1771
|
+
if not jail:
|
|
1772
|
+
return "Error: No project."
|
|
1646
1773
|
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
|
1647
1774
|
count = 0
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1775
|
+
know_dir = os.path.join(jail, "knowledge")
|
|
1776
|
+
if os.path.exists(know_dir):
|
|
1777
|
+
for fname in os.listdir(know_dir):
|
|
1778
|
+
if not ki_utils.is_ki_filename(fname, include_overview=True):
|
|
1779
|
+
continue
|
|
1780
|
+
filepath = os.path.join(know_dir, fname)
|
|
1781
|
+
if not os.path.isfile(filepath):
|
|
1782
|
+
continue
|
|
1783
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
1784
|
+
content = f.read()
|
|
1785
|
+
|
|
1786
|
+
new_content = content
|
|
1787
|
+
new_content = re.sub(
|
|
1788
|
+
r'<!--\s*last_verified:\s*"?\d{4}-\d{2}-\d{2}"?\s*-->',
|
|
1789
|
+
f'<!-- last_verified: {today} -->',
|
|
1790
|
+
new_content,
|
|
1791
|
+
flags=re.IGNORECASE
|
|
1792
|
+
)
|
|
1793
|
+
new_content = re.sub(
|
|
1794
|
+
r'^(last_verified:\s*)"?\d{4}-\d{2}-\d{2}"?',
|
|
1795
|
+
rf'\g<1>"{today}"',
|
|
1796
|
+
new_content,
|
|
1797
|
+
flags=re.MULTILINE
|
|
1798
|
+
)
|
|
1799
|
+
new_content = re.sub(
|
|
1800
|
+
r'(\*\*Last verified\*\*:\s*)"?\d{4}-\d{2}-\d{2}"?',
|
|
1801
|
+
rf'\g<1>{today}',
|
|
1802
|
+
new_content,
|
|
1803
|
+
flags=re.IGNORECASE
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
if new_content == content:
|
|
1807
|
+
lines = content.splitlines(keepends=True)
|
|
1808
|
+
res_lines = []
|
|
1809
|
+
inserted = False
|
|
1810
|
+
for line in lines:
|
|
1811
|
+
res_lines.append(line)
|
|
1812
|
+
if not inserted and line.startswith("# "):
|
|
1813
|
+
res_lines.append(f"\n<!-- last_verified: {today} -->\n")
|
|
1814
|
+
inserted = True
|
|
1815
|
+
if not inserted:
|
|
1816
|
+
res_lines.insert(0, f"<!-- last_verified: {today} -->\n\n")
|
|
1817
|
+
new_content = "".join(res_lines)
|
|
1818
|
+
|
|
1819
|
+
if new_content != content:
|
|
1820
|
+
import tempfile
|
|
1821
|
+
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(filepath), text=True)
|
|
1822
|
+
try:
|
|
1823
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
1824
|
+
f.write(new_content)
|
|
1825
|
+
os.replace(tmp_path, filepath)
|
|
1826
|
+
count += 1
|
|
1827
|
+
except Exception:
|
|
1828
|
+
if os.path.exists(tmp_path):
|
|
1829
|
+
os.remove(tmp_path)
|
|
1662
1830
|
return f"Updated last_verified in {count} files."
|
|
1663
1831
|
if name == "analyze_all_dependencies":
|
|
1664
1832
|
return run_script("ki_dependency_analyzer.py", ["--all"])
|
|
@@ -1730,7 +1898,8 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1730
1898
|
|
|
1731
1899
|
# ─── File Ops ───
|
|
1732
1900
|
if name == "read_know_file":
|
|
1733
|
-
|
|
1901
|
+
p = resolve_know_path(args["rel_path"], must_exist=True)
|
|
1902
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
1734
1903
|
content = f.read()
|
|
1735
1904
|
offset = 0
|
|
1736
1905
|
if args.get("offset") is not None:
|
|
@@ -1754,7 +1923,7 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1754
1923
|
return content[offset:]
|
|
1755
1924
|
return content
|
|
1756
1925
|
if name == "write_know_file":
|
|
1757
|
-
p =
|
|
1926
|
+
p = resolve_know_path(args["rel_path"], is_write=True, must_exist=False)
|
|
1758
1927
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
1759
1928
|
import tempfile
|
|
1760
1929
|
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(p), text=True)
|
|
@@ -1768,7 +1937,7 @@ def handle_tool_call(name: str, args: dict) -> Any:
|
|
|
1768
1937
|
raise e
|
|
1769
1938
|
return "File written."
|
|
1770
1939
|
if name == "edit_know_file":
|
|
1771
|
-
p =
|
|
1940
|
+
p = resolve_know_path(args["rel_path"], is_write=True, must_exist=True)
|
|
1772
1941
|
with open(p, "r", encoding="utf-8") as f:
|
|
1773
1942
|
content = f.read()
|
|
1774
1943
|
old_text = args["old_text"].replace("\r\n", "\n")
|
|
@@ -1904,6 +2073,15 @@ def _write_ide_instructions() -> None:
|
|
|
1904
2073
|
instructions_path.write_text(content, encoding="utf-8")
|
|
1905
2074
|
safe_log(f"Wrote instructions.md → {instructions_path}")
|
|
1906
2075
|
|
|
2076
|
+
active_tool_names = {t["name"] for t in tools_to_write}
|
|
2077
|
+
for existing_json in target_dir.glob("*.json"):
|
|
2078
|
+
if existing_json.stem not in active_tool_names:
|
|
2079
|
+
try:
|
|
2080
|
+
existing_json.unlink()
|
|
2081
|
+
safe_log(f"Removed inactive tool schema: {existing_json.name}")
|
|
2082
|
+
except Exception:
|
|
2083
|
+
pass
|
|
2084
|
+
|
|
1907
2085
|
# Sync tool schema JSON files if target is an IDE mcp tool directory
|
|
1908
2086
|
for tool in tools_to_write:
|
|
1909
2087
|
try:
|
|
@@ -1921,7 +2099,7 @@ def _write_ide_instructions() -> None:
|
|
|
1921
2099
|
|
|
1922
2100
|
|
|
1923
2101
|
def _extract_workspace_from_meta(params: dict) -> Optional[str]:
|
|
1924
|
-
"""
|
|
2102
|
+
"""Извлекает workspace URI из _meta нового протокола (2026-07-28)."""
|
|
1925
2103
|
meta = params.get("_meta", {})
|
|
1926
2104
|
client_info = meta.get("io.modelcontextprotocol/clientInfo", {})
|
|
1927
2105
|
return client_info.get("workspaceUri") or client_info.get("rootUri")
|
|
@@ -1964,6 +2142,7 @@ def main():
|
|
|
1964
2142
|
root_uri = roots[0].get("uri")
|
|
1965
2143
|
if root_uri:
|
|
1966
2144
|
ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(root_uri)
|
|
2145
|
+
ki_utils._CACHE.clear()
|
|
1967
2146
|
safe_log(f"SET workspace via roots/list: {ki_utils.ACTIVE_WORKSPACE_PATH}")
|
|
1968
2147
|
continue
|
|
1969
2148
|
|
|
@@ -1971,24 +2150,22 @@ def main():
|
|
|
1971
2150
|
method = req.get("method")
|
|
1972
2151
|
params = req.get("params", {})
|
|
1973
2152
|
|
|
1974
|
-
#
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
2153
|
+
# Извлекаем workspace из _meta при каждом запросе
|
|
2154
|
+
_ws = _extract_workspace_from_meta(params)
|
|
2155
|
+
if _ws:
|
|
2156
|
+
norm_ws = ki_utils.normalize_path(_ws)
|
|
2157
|
+
if norm_ws != ki_utils.ACTIVE_WORKSPACE_PATH:
|
|
2158
|
+
ki_utils.ACTIVE_WORKSPACE_PATH = norm_ws
|
|
2159
|
+
ki_utils._CACHE.clear()
|
|
1979
2160
|
safe_log(f"SET workspace via _meta: {ki_utils.ACTIVE_WORKSPACE_PATH}")
|
|
1980
2161
|
|
|
1981
2162
|
def send(result_data):
|
|
1982
2163
|
resp = json.dumps({"jsonrpc": "2.0", "id": rid, "result": result_data})
|
|
1983
|
-
safe_log(f"DEBUG: Before write RESP")
|
|
1984
2164
|
safe_log(f"RESP: {resp}")
|
|
1985
2165
|
sys.stdout.write(resp + "\n")
|
|
1986
|
-
safe_log(f"DEBUG: Before flush RESP")
|
|
1987
2166
|
sys.stdout.flush()
|
|
1988
|
-
safe_log(f"DEBUG: After flush RESP")
|
|
1989
2167
|
|
|
1990
2168
|
if method in ("initialize", "server/discover"):
|
|
1991
|
-
safe_log(f"DEBUG: Inside method {method}")
|
|
1992
2169
|
# Extract workspace URI from any location in params
|
|
1993
2170
|
root_uri = params.get("rootUri")
|
|
1994
2171
|
if not root_uri:
|
|
@@ -2012,20 +2189,16 @@ def main():
|
|
|
2012
2189
|
return d
|
|
2013
2190
|
root_uri = _find_uri(params)
|
|
2014
2191
|
|
|
2015
|
-
safe_log(f"DEBUG: After _find_uri. root_uri={root_uri}")
|
|
2016
2192
|
if root_uri:
|
|
2017
2193
|
ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(root_uri)
|
|
2194
|
+
ki_utils._CACHE.clear()
|
|
2018
2195
|
safe_log(f"SET workspace via initialize: {ki_utils.ACTIVE_WORKSPACE_PATH}")
|
|
2019
2196
|
|
|
2020
|
-
safe_log(f"DEBUG: Before importlib")
|
|
2021
2197
|
try:
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
safe_log(f"DEBUG: importlib EXCEPTION: {ex}")
|
|
2027
|
-
server_version = "2.0.11"
|
|
2028
|
-
safe_log(f"DEBUG: After importlib. version={server_version}")
|
|
2198
|
+
import ki_manager
|
|
2199
|
+
server_version = getattr(ki_manager, "__version__", importlib.metadata.version("ki-manager"))
|
|
2200
|
+
except Exception:
|
|
2201
|
+
server_version = "2.3.0"
|
|
2029
2202
|
|
|
2030
2203
|
know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
|
|
2031
2204
|
resp = {
|
|
@@ -2037,7 +2210,6 @@ def main():
|
|
|
2037
2210
|
if method == "server/discover":
|
|
2038
2211
|
resp["versions"] = ["2026-07-28", "2025-11-25", "2024-11-05"]
|
|
2039
2212
|
send(resp)
|
|
2040
|
-
safe_log(f"DEBUG: After send() for {method}")
|
|
2041
2213
|
|
|
2042
2214
|
elif method == "notifications/initialized":
|
|
2043
2215
|
# Request roots from client for workspace detection
|
|
@@ -2047,17 +2219,18 @@ def main():
|
|
|
2047
2219
|
sys.stdout.flush()
|
|
2048
2220
|
|
|
2049
2221
|
elif method == "tools/list":
|
|
2222
|
+
facade.sync_dispatch_schemas(MCP_TOOLS)
|
|
2050
2223
|
tools_list = COMPACT_TOOLS if tool_mode == "compact" else MCP_TOOLS
|
|
2051
2224
|
send({"tools": tools_list, "ttlMs": 300000, "cacheScope": "global"})
|
|
2052
2225
|
|
|
2053
2226
|
elif method == "tools/call":
|
|
2054
2227
|
tool_name = params["name"]
|
|
2055
2228
|
tool_args = params.get("arguments", {})
|
|
2056
|
-
# Fallback: пробŃем взять workspace из аргŃментов инŃтрŃмента
|
|
2057
2229
|
if not ki_utils.ACTIVE_WORKSPACE_PATH:
|
|
2058
2230
|
_ws_arg = tool_args.get("path") or tool_args.get("project_path")
|
|
2059
2231
|
if _ws_arg:
|
|
2060
2232
|
ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(_ws_arg)
|
|
2233
|
+
ki_utils._CACHE.clear()
|
|
2061
2234
|
safe_log(f"SET workspace via tool args: {ki_utils.ACTIVE_WORKSPACE_PATH}")
|
|
2062
2235
|
result = handle_tool_call(tool_name, tool_args)
|
|
2063
2236
|
if isinstance(result, dict) and "content" in result:
|
|
@@ -2079,10 +2252,15 @@ def main():
|
|
|
2079
2252
|
if not match:
|
|
2080
2253
|
content = "No registered project. Run ki_init_project first."
|
|
2081
2254
|
else:
|
|
2082
|
-
|
|
2255
|
+
ki_table = ki_utils.get_ki_list_table()
|
|
2256
|
+
adr_table = ki_utils.get_adr_table()
|
|
2257
|
+
if adr_table and not adr_table.startswith("No ADRs found"):
|
|
2258
|
+
content = f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
|
|
2259
|
+
else:
|
|
2260
|
+
content = f"Knowledge Items for this project:\n\n{ki_table}"
|
|
2083
2261
|
else:
|
|
2084
|
-
wf_path = _WORKFLOWS_DIR / f"{prompt_name}.md"
|
|
2085
|
-
if wf_path.exists():
|
|
2262
|
+
wf_path = (_WORKFLOWS_DIR / f"{prompt_name}.md").resolve()
|
|
2263
|
+
if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
|
|
2086
2264
|
with open(wf_path, "r", encoding="utf-8") as f:
|
|
2087
2265
|
raw = f.read()
|
|
2088
2266
|
know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
|
|
@@ -2142,7 +2320,12 @@ def main():
|
|
|
2142
2320
|
know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
|
|
2143
2321
|
content = facade.render_instruction(GLOBAL_INSTRUCTIONS, CURRENT_TOOL_MODE, know_name)
|
|
2144
2322
|
elif uri == "ki://knowledge-items.md":
|
|
2145
|
-
|
|
2323
|
+
ki_table = ki_utils.get_ki_list_table()
|
|
2324
|
+
adr_table = ki_utils.get_adr_table()
|
|
2325
|
+
if adr_table and not adr_table.startswith("No ADRs found"):
|
|
2326
|
+
content = f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
|
|
2327
|
+
else:
|
|
2328
|
+
content = f"Knowledge Items for this project:\n\n{ki_table}"
|
|
2146
2329
|
elif uri == "ki://adr-list.md":
|
|
2147
2330
|
content = get_adr_list(get_project_root(), jail) if jail else "No active project."
|
|
2148
2331
|
elif uri in ("ki://workflows/", "ki://workflows"):
|
|
@@ -2157,8 +2340,8 @@ def main():
|
|
|
2157
2340
|
wf_name = uri.replace("ki://workflows/", "")
|
|
2158
2341
|
if not wf_name.endswith(".md"):
|
|
2159
2342
|
wf_name += ".md"
|
|
2160
|
-
wf_path = _WORKFLOWS_DIR / wf_name
|
|
2161
|
-
if wf_path.exists():
|
|
2343
|
+
wf_path = (_WORKFLOWS_DIR / wf_name).resolve()
|
|
2344
|
+
if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
|
|
2162
2345
|
with open(wf_path, "r", encoding="utf-8") as f:
|
|
2163
2346
|
raw = f.read()
|
|
2164
2347
|
know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
|
|
@@ -2167,10 +2350,13 @@ def main():
|
|
|
2167
2350
|
# Physical resources in jail
|
|
2168
2351
|
elif jail and uri.startswith("ki://"):
|
|
2169
2352
|
fname = uri.replace("ki://", "")
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2353
|
+
try:
|
|
2354
|
+
fpath = validate_path(fname, is_write=False)
|
|
2355
|
+
if os.path.exists(fpath) and os.path.isfile(fpath):
|
|
2356
|
+
with open(fpath, "r", encoding="utf-8") as f:
|
|
2357
|
+
content = f.read()
|
|
2358
|
+
except Exception:
|
|
2359
|
+
content = None
|
|
2174
2360
|
|
|
2175
2361
|
if content is not None:
|
|
2176
2362
|
send({"contents": [{"uri": uri, "mimeType": "text/plain", "text": content}]})
|
|
@@ -244,6 +244,46 @@ KI_MUTATE_TOOL = {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
# Direct facade tools that cannot be called recursively via ki_call / ki_mutate
|
|
248
|
+
DIRECT_FACADE_TOOLS = {"ki_instructions", "ki_search", "ki_read", "ki_tools", "ki_call", "ki_mutate"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def sync_dispatch_schemas(all_tools: List[Dict[str, Any]]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
252
|
+
"""Dynamically binds KI_CALL_TOOL and KI_MUTATE_TOOL schemas and descriptions
|
|
253
|
+
directly to the readOnlyHint annotations of all registered tools."""
|
|
254
|
+
ro_tools = []
|
|
255
|
+
mut_tools = []
|
|
256
|
+
|
|
257
|
+
for t in all_tools:
|
|
258
|
+
name = t.get("name")
|
|
259
|
+
if not name or name in DIRECT_FACADE_TOOLS:
|
|
260
|
+
continue
|
|
261
|
+
is_ro = t.get("annotations", {}).get("readOnlyHint", False)
|
|
262
|
+
if is_ro:
|
|
263
|
+
ro_tools.append(name)
|
|
264
|
+
else:
|
|
265
|
+
mut_tools.append(name)
|
|
266
|
+
|
|
267
|
+
ro_tools.sort()
|
|
268
|
+
mut_tools.sort()
|
|
269
|
+
|
|
270
|
+
KI_CALL_TOOL["description"] = (
|
|
271
|
+
"Execute a read-only ki-manager tool. "
|
|
272
|
+
f"Available tools: {', '.join(ro_tools)}. "
|
|
273
|
+
"Use ki_tools(name=...) to check arguments. For mutating operations, use ki_mutate."
|
|
274
|
+
)
|
|
275
|
+
KI_CALL_TOOL["inputSchema"]["properties"]["tool"]["enum"] = ro_tools
|
|
276
|
+
|
|
277
|
+
KI_MUTATE_TOOL["description"] = (
|
|
278
|
+
"Execute a state-modifying ki-manager tool. "
|
|
279
|
+
f"Available tools: {', '.join(mut_tools)}. "
|
|
280
|
+
"Use ki_tools(name=...) to check arguments."
|
|
281
|
+
)
|
|
282
|
+
KI_MUTATE_TOOL["inputSchema"]["properties"]["tool"]["enum"] = mut_tools
|
|
283
|
+
|
|
284
|
+
return KI_CALL_TOOL, KI_MUTATE_TOOL
|
|
285
|
+
|
|
286
|
+
|
|
247
287
|
# ─── Helper Functions ────────────────────────────────────────────────────────
|
|
248
288
|
|
|
249
289
|
def format_tools_catalog(all_tools: List[Dict[str, Any]], group_filter: Optional[str] = None) -> str:
|
|
@@ -280,7 +320,7 @@ def format_tools_catalog(all_tools: List[Dict[str, Any]], group_filter: Optional
|
|
|
280
320
|
|
|
281
321
|
|
|
282
322
|
def validate_args_against_schema(schema: Dict[str, Any], args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
283
|
-
"""Simple schema validator checking required fields and basic types."""
|
|
323
|
+
"""Simple schema validator checking required fields, enum choices, and basic types."""
|
|
284
324
|
properties = schema.get("properties", {})
|
|
285
325
|
required = schema.get("required", [])
|
|
286
326
|
|
|
@@ -307,6 +347,10 @@ def validate_args_against_schema(schema: Dict[str, Any], args: Dict[str, Any]) -
|
|
|
307
347
|
return False, f"Parameter '{key}' must be integer, got boolean."
|
|
308
348
|
if not isinstance(val, expected_type):
|
|
309
349
|
return False, f"Parameter '{key}' must be of type {expected_type_str}, got {type(val).__name__}."
|
|
350
|
+
if "enum" in properties[key]:
|
|
351
|
+
allowed = properties[key]["enum"]
|
|
352
|
+
if val not in allowed:
|
|
353
|
+
return False, f"Parameter '{key}' value '{val}' is not one of {allowed}."
|
|
310
354
|
|
|
311
355
|
return True, None
|
|
312
356
|
|
|
@@ -42,13 +42,14 @@ KI_CONFIG_TEMPLATE = {
|
|
|
42
42
|
"knowledge_system": {
|
|
43
43
|
"mcp_server": {
|
|
44
44
|
"name": "ki-manager",
|
|
45
|
-
"version": "2.
|
|
45
|
+
"version": "2.3.0"
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"required_sections": ["Overview", "Key Components", "Related KIs"]
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
OVERVIEW_KI_TEMPLATE = """\
|
|
52
|
+
<!-- last_verified: {date} -->
|
|
52
53
|
# _OVERVIEW — Project Overview
|
|
53
54
|
|
|
54
55
|
**Module**: `{project_name}` (entire project)
|
|
@@ -126,10 +127,10 @@ def write_text(path: Path, content: str):
|
|
|
126
127
|
f.write(content)
|
|
127
128
|
|
|
128
129
|
|
|
129
|
-
def update_gitignore(project_root: Path):
|
|
130
|
-
"""Append
|
|
130
|
+
def update_gitignore(project_root: Path, know_name: str = ".ki-base"):
|
|
131
|
+
"""Append <know_name>/config.json to the project .gitignore if not already present."""
|
|
131
132
|
gitignore = project_root / ".gitignore"
|
|
132
|
-
line = "
|
|
133
|
+
line = f"{know_name}/config.json"
|
|
133
134
|
if gitignore.exists():
|
|
134
135
|
content = gitignore.read_text(encoding="utf-8")
|
|
135
136
|
if line in content:
|
|
@@ -221,7 +222,7 @@ def init_project(args: dict) -> str:
|
|
|
221
222
|
log.append(f"[+] Created {know_name}/knowledge/_OVERVIEW.ki.md")
|
|
222
223
|
|
|
223
224
|
if cfg_loc != "root":
|
|
224
|
-
update_gitignore(project_path)
|
|
225
|
+
update_gitignore(project_path, know_name=know_name)
|
|
225
226
|
|
|
226
227
|
success, msg = ki_utils.register_project(config_path=str(ki_config_path), workspace=str(project_path))
|
|
227
228
|
if success:
|
|
@@ -290,7 +291,7 @@ def migrate_project(project_root: str) -> str:
|
|
|
290
291
|
overview_path = knowledge_dir / "_OVERVIEW.ki.md"
|
|
291
292
|
if not overview_path.exists():
|
|
292
293
|
try:
|
|
293
|
-
today = datetime.
|
|
294
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
294
295
|
project_name = root.name
|
|
295
296
|
if doc_config_path.exists():
|
|
296
297
|
try:
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/expand-knowledge.md
RENAMED
|
@@ -139,10 +139,10 @@ Write the file using `write_know_file` or `edit_know_file`:
|
|
|
139
139
|
Register the new KI in `doc_config.json`:
|
|
140
140
|
<!-- if-compact -->
|
|
141
141
|
// turbo
|
|
142
|
-
`ki_mutate(tool="add_ki_to_config", args={"ki_name": "<filename>.ki.md", "
|
|
142
|
+
`ki_mutate(tool="add_ki_to_config", args={"ki_name": "<filename>.ki.md", "description": "<Full description>", "depends_on": ["path/to/tracked/file.py"], "summary": "<Concise summary>"})`
|
|
143
143
|
<!-- else-compact -->
|
|
144
144
|
// turbo
|
|
145
|
-
`add_ki_to_config(ki_name="<filename>.ki.md",
|
|
145
|
+
`add_ki_to_config(ki_name="<filename>.ki.md", description="<Full description>", depends_on=["path/to/tracked/file.py"], summary="<Concise summary>")`
|
|
146
146
|
<!-- /if-compact -->
|
|
147
147
|
|
|
148
148
|
---
|
|
@@ -155,7 +155,7 @@ Automatically identify and link related KIs based on code imports:
|
|
|
155
155
|
<!-- if-compact -->
|
|
156
156
|
`ki_call(tool="analyze_dependencies", args={"ki_name": "<filename>.ki.md", "only_changed": false})`
|
|
157
157
|
<!-- else-compact -->
|
|
158
|
-
`analyze_dependencies(
|
|
158
|
+
`analyze_dependencies(ki_name="<filename>.ki.md", only_changed=false)`
|
|
159
159
|
<!-- /if-compact -->
|
|
160
160
|
|
|
161
161
|
---
|
|
@@ -188,7 +188,7 @@ Finalize the expansion by creating a git snapshot of the knowledge state.
|
|
|
188
188
|
<!-- if-compact -->
|
|
189
189
|
`ki_mutate(tool="git_checkpoint", args={"message": "Expand knowledge base: new KI registration"})`
|
|
190
190
|
<!-- else-compact -->
|
|
191
|
-
`git_checkpoint(
|
|
191
|
+
`git_checkpoint(message="Expand knowledge base: new KI registration")`
|
|
192
192
|
<!-- /if-compact -->
|
|
193
193
|
|
|
194
194
|
---
|
|
@@ -100,7 +100,7 @@ Update inter-KI links ONLY for modified KIs to minimize Git noise.
|
|
|
100
100
|
<!-- if-compact -->
|
|
101
101
|
`ki_call(tool="analyze_dependencies", args={"only_changed": true})`
|
|
102
102
|
<!-- else-compact -->
|
|
103
|
-
`analyze_dependencies(
|
|
103
|
+
`analyze_dependencies(only_changed=true)`
|
|
104
104
|
<!-- /if-compact -->
|
|
105
105
|
|
|
106
106
|
> [!NOTE]
|
|
@@ -114,5 +114,5 @@ Finalize the synchronization by creating a git snapshot of the knowledge state.
|
|
|
114
114
|
<!-- if-compact -->
|
|
115
115
|
`ki_mutate(tool="git_checkpoint", args={"message": "Sync knowledge system state"})`
|
|
116
116
|
<!-- else-compact -->
|
|
117
|
-
`git_checkpoint(
|
|
117
|
+
`git_checkpoint(message="Sync knowledge system state")`
|
|
118
118
|
<!-- /if-compact -->
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/generate_dir_index.py
RENAMED
|
File without changes
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_dependency_analyzer.py
RENAMED
|
File without changes
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/scripts/ki_graph_visualize.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ki_manager-2.3.0.dev3 → ki_manager-2.3.1.dev1}/src/ki_manager/workflows/scaffold-knowledge.md
RENAMED
|
File without changes
|