monoco-toolkit 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- monoco/cli/project.py +35 -31
- monoco/cli/workspace.py +26 -16
- monoco/core/agent/__init__.py +0 -2
- monoco/core/agent/action.py +44 -20
- monoco/core/agent/adapters.py +20 -16
- monoco/core/agent/protocol.py +5 -4
- monoco/core/agent/state.py +21 -21
- monoco/core/config.py +90 -33
- monoco/core/execution.py +21 -16
- monoco/core/feature.py +8 -5
- monoco/core/git.py +61 -30
- monoco/core/hooks.py +57 -0
- monoco/core/injection.py +47 -44
- monoco/core/integrations.py +50 -35
- monoco/core/lsp.py +12 -1
- monoco/core/output.py +35 -16
- monoco/core/registry.py +3 -2
- monoco/core/setup.py +190 -124
- monoco/core/skills.py +121 -107
- monoco/core/state.py +12 -10
- monoco/core/sync.py +85 -56
- monoco/core/telemetry.py +10 -6
- monoco/core/workspace.py +26 -19
- monoco/daemon/app.py +123 -79
- monoco/daemon/commands.py +14 -13
- monoco/daemon/models.py +11 -3
- monoco/daemon/reproduce_stats.py +8 -8
- monoco/daemon/services.py +32 -33
- monoco/daemon/stats.py +59 -40
- monoco/features/config/commands.py +38 -25
- monoco/features/i18n/adapter.py +4 -5
- monoco/features/i18n/commands.py +83 -49
- monoco/features/i18n/core.py +94 -54
- monoco/features/issue/adapter.py +6 -7
- monoco/features/issue/commands.py +500 -260
- monoco/features/issue/core.py +504 -293
- monoco/features/issue/domain/lifecycle.py +33 -23
- monoco/features/issue/domain/models.py +71 -38
- monoco/features/issue/domain/parser.py +92 -69
- monoco/features/issue/domain/workspace.py +19 -16
- monoco/features/issue/engine/__init__.py +3 -3
- monoco/features/issue/engine/config.py +18 -25
- monoco/features/issue/engine/machine.py +72 -39
- monoco/features/issue/engine/models.py +4 -2
- monoco/features/issue/linter.py +326 -111
- monoco/features/issue/lsp/definition.py +26 -19
- monoco/features/issue/migration.py +45 -34
- monoco/features/issue/models.py +30 -13
- monoco/features/issue/monitor.py +24 -8
- monoco/features/issue/resources/en/AGENTS.md +5 -0
- monoco/features/issue/resources/en/SKILL.md +30 -2
- monoco/features/issue/resources/zh/AGENTS.md +5 -0
- monoco/features/issue/resources/zh/SKILL.md +26 -1
- monoco/features/issue/validator.py +417 -172
- monoco/features/skills/__init__.py +0 -1
- monoco/features/skills/core.py +24 -18
- monoco/features/spike/adapter.py +4 -5
- monoco/features/spike/commands.py +51 -38
- monoco/features/spike/core.py +24 -16
- monoco/main.py +34 -21
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/METADATA +10 -3
- monoco_toolkit-0.3.0.dist-info/RECORD +84 -0
- monoco_toolkit-0.2.7.dist-info/RECORD +0 -83
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/licenses/LICENSE +0 -0
monoco/core/git.py
CHANGED
|
@@ -6,24 +6,23 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
logger = logging.getLogger("monoco.core.git")
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
def _run_git(args: List[str], cwd: Path) -> Tuple[int, str, str]:
|
|
10
11
|
"""Run a raw git command."""
|
|
11
12
|
try:
|
|
12
13
|
result = subprocess.run(
|
|
13
|
-
["git"] + args,
|
|
14
|
-
cwd=cwd,
|
|
15
|
-
capture_output=True,
|
|
16
|
-
text=True,
|
|
17
|
-
check=False
|
|
14
|
+
["git"] + args, cwd=cwd, capture_output=True, text=True, check=False
|
|
18
15
|
)
|
|
19
16
|
return result.returncode, result.stdout, result.stderr
|
|
20
17
|
except FileNotFoundError:
|
|
21
18
|
return 1, "", "Git executable not found"
|
|
22
19
|
|
|
20
|
+
|
|
23
21
|
def is_git_repo(path: Path) -> bool:
|
|
24
22
|
code, _, _ = _run_git(["rev-parse", "--is-inside-work-tree"], path)
|
|
25
23
|
return code == 0
|
|
26
24
|
|
|
25
|
+
|
|
27
26
|
def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
|
|
28
27
|
"""
|
|
29
28
|
Get list of modified files.
|
|
@@ -32,11 +31,11 @@ def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
|
|
|
32
31
|
cmd = ["status", "--porcelain"]
|
|
33
32
|
if subpath:
|
|
34
33
|
cmd.append(subpath)
|
|
35
|
-
|
|
34
|
+
|
|
36
35
|
code, stdout, _ = _run_git(cmd, path)
|
|
37
36
|
if code != 0:
|
|
38
37
|
raise RuntimeError("Failed to check git status")
|
|
39
|
-
|
|
38
|
+
|
|
40
39
|
lines = []
|
|
41
40
|
for line in stdout.splitlines():
|
|
42
41
|
line = line.strip()
|
|
@@ -50,6 +49,7 @@ def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
|
|
|
50
49
|
lines.append(path_str)
|
|
51
50
|
return lines
|
|
52
51
|
|
|
52
|
+
|
|
53
53
|
def git_add(path: Path, files: List[str]) -> None:
|
|
54
54
|
if not files:
|
|
55
55
|
return
|
|
@@ -57,43 +57,46 @@ def git_add(path: Path, files: List[str]) -> None:
|
|
|
57
57
|
if code != 0:
|
|
58
58
|
raise RuntimeError(f"Git add failed: {stderr}")
|
|
59
59
|
|
|
60
|
+
|
|
60
61
|
def git_commit(path: Path, message: str) -> str:
|
|
61
62
|
code, stdout, stderr = _run_git(["commit", "-m", message], path)
|
|
62
63
|
if code != 0:
|
|
63
64
|
raise RuntimeError(f"Git commit failed: {stderr}")
|
|
64
|
-
|
|
65
|
+
|
|
65
66
|
code, hash_out, _ = _run_git(["rev-parse", "HEAD"], path)
|
|
66
67
|
return hash_out.strip()
|
|
67
68
|
|
|
69
|
+
|
|
68
70
|
def search_commits_by_message(path: Path, grep_pattern: str) -> List[Dict[str, str]]:
|
|
69
71
|
cmd = ["log", f"--grep={grep_pattern}", "--name-only", "--format=COMMIT:%H|%s"]
|
|
70
72
|
code, stdout, stderr = _run_git(cmd, path)
|
|
71
73
|
if code != 0:
|
|
72
74
|
raise RuntimeError(f"Git log failed: {stderr}")
|
|
73
|
-
|
|
75
|
+
|
|
74
76
|
commits = []
|
|
75
77
|
current_commit = None
|
|
76
|
-
|
|
78
|
+
|
|
77
79
|
for line in stdout.splitlines():
|
|
78
80
|
if line.startswith("COMMIT:"):
|
|
79
81
|
if current_commit:
|
|
80
82
|
commits.append(current_commit)
|
|
81
|
-
|
|
83
|
+
|
|
82
84
|
parts = line[7:].split("|", 1)
|
|
83
85
|
current_commit = {
|
|
84
86
|
"hash": parts[0],
|
|
85
87
|
"subject": parts[1] if len(parts) > 1 else "",
|
|
86
|
-
"files": []
|
|
88
|
+
"files": [],
|
|
87
89
|
}
|
|
88
90
|
elif line.strip():
|
|
89
91
|
if current_commit:
|
|
90
92
|
current_commit["files"].append(line.strip())
|
|
91
|
-
|
|
93
|
+
|
|
92
94
|
if current_commit:
|
|
93
95
|
commits.append(current_commit)
|
|
94
|
-
|
|
96
|
+
|
|
95
97
|
return commits
|
|
96
98
|
|
|
99
|
+
|
|
97
100
|
def get_commit_stats(path: Path, commit_hash: str) -> Dict[str, int]:
|
|
98
101
|
cmd = ["show", "--shortstat", "--format=", commit_hash]
|
|
99
102
|
code, stdout, _ = _run_git(cmd, path)
|
|
@@ -110,87 +113,111 @@ def get_commit_stats(path: Path, commit_hash: str) -> Dict[str, int]:
|
|
|
110
113
|
stats["deletions"] = int(p.split()[0])
|
|
111
114
|
return stats
|
|
112
115
|
|
|
116
|
+
|
|
113
117
|
# --- Branch & Worktree Extensions ---
|
|
114
118
|
|
|
119
|
+
|
|
115
120
|
def get_current_branch(path: Path) -> str:
|
|
116
121
|
code, stdout, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], path)
|
|
117
122
|
if code != 0:
|
|
118
123
|
return ""
|
|
119
124
|
return stdout.strip()
|
|
120
125
|
|
|
126
|
+
|
|
121
127
|
def branch_exists(path: Path, branch_name: str) -> bool:
|
|
122
128
|
code, _, _ = _run_git(["rev-parse", "--verify", branch_name], path)
|
|
123
129
|
return code == 0
|
|
124
130
|
|
|
131
|
+
|
|
125
132
|
def create_branch(path: Path, branch_name: str, checkout: bool = False):
|
|
126
133
|
cmd = ["checkout", "-b", branch_name] if checkout else ["branch", branch_name]
|
|
127
134
|
code, _, stderr = _run_git(cmd, path)
|
|
128
135
|
if code != 0:
|
|
129
136
|
raise RuntimeError(f"Failed to create branch {branch_name}: {stderr}")
|
|
130
137
|
|
|
138
|
+
|
|
131
139
|
def checkout_branch(path: Path, branch_name: str):
|
|
132
140
|
code, _, stderr = _run_git(["checkout", branch_name], path)
|
|
133
141
|
if code != 0:
|
|
134
142
|
raise RuntimeError(f"Failed to checkout {branch_name}: {stderr}")
|
|
135
143
|
|
|
144
|
+
|
|
136
145
|
def delete_branch(path: Path, branch_name: str, force: bool = False):
|
|
137
146
|
flag = "-D" if force else "-d"
|
|
138
147
|
code, _, stderr = _run_git(["branch", flag, branch_name], path)
|
|
139
148
|
if code != 0:
|
|
140
149
|
raise RuntimeError(f"Failed to delete branch {branch_name}: {stderr}")
|
|
141
150
|
|
|
151
|
+
|
|
142
152
|
def get_worktrees(path: Path) -> List[Tuple[str, str, str]]:
|
|
143
153
|
"""Returns list of (path, head, branch)"""
|
|
144
154
|
code, stdout, stderr = _run_git(["worktree", "list", "--porcelain"], path)
|
|
145
155
|
if code != 0:
|
|
146
156
|
raise RuntimeError(f"Failed to list worktrees: {stderr}")
|
|
147
|
-
|
|
157
|
+
|
|
148
158
|
trees = []
|
|
149
159
|
current = {}
|
|
150
160
|
for line in stdout.splitlines():
|
|
151
161
|
if line.startswith("worktree "):
|
|
152
162
|
if current:
|
|
153
|
-
trees.append(
|
|
163
|
+
trees.append(
|
|
164
|
+
(
|
|
165
|
+
current.get("worktree"),
|
|
166
|
+
current.get("HEAD"),
|
|
167
|
+
current.get("branch"),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
154
170
|
current = {"worktree": line[9:].strip()}
|
|
155
171
|
elif line.startswith("HEAD "):
|
|
156
172
|
current["HEAD"] = line[5:].strip()
|
|
157
173
|
elif line.startswith("branch "):
|
|
158
174
|
current["branch"] = line[7:].strip()
|
|
159
|
-
|
|
175
|
+
|
|
160
176
|
if current:
|
|
161
|
-
trees.append(
|
|
177
|
+
trees.append(
|
|
178
|
+
(current.get("worktree"), current.get("HEAD"), current.get("branch"))
|
|
179
|
+
)
|
|
162
180
|
return trees
|
|
163
181
|
|
|
182
|
+
|
|
164
183
|
def worktree_add(path: Path, branch_name: str, worktree_path: Path):
|
|
165
|
-
# If branch doesn't exist, -b will create it.
|
|
184
|
+
# If branch doesn't exist, -b will create it.
|
|
166
185
|
# Logic: git worktree add [-b <new_branch>] <path> <commit-ish>
|
|
167
|
-
|
|
186
|
+
|
|
168
187
|
# We assume if branch_exists, use it. If not, create it.
|
|
169
188
|
cmd = ["worktree", "add"]
|
|
170
189
|
if not branch_exists(path, branch_name):
|
|
171
190
|
cmd.extend(["-b", branch_name])
|
|
172
|
-
|
|
191
|
+
|
|
173
192
|
cmd.extend([str(worktree_path), branch_name])
|
|
174
|
-
|
|
193
|
+
|
|
175
194
|
code, _, stderr = _run_git(cmd, path)
|
|
176
195
|
if code != 0:
|
|
177
196
|
raise RuntimeError(f"Failed to create worktree: {stderr}")
|
|
178
197
|
|
|
198
|
+
|
|
179
199
|
def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
|
|
180
200
|
cmd = ["worktree", "remove"]
|
|
181
201
|
if force:
|
|
182
202
|
cmd.append("--force")
|
|
183
203
|
cmd.append(str(worktree_path))
|
|
184
|
-
|
|
204
|
+
|
|
185
205
|
code, _, stderr = _run_git(cmd, path)
|
|
186
206
|
if code != 0:
|
|
187
207
|
raise RuntimeError(f"Failed to remove worktree: {stderr}")
|
|
188
208
|
|
|
209
|
+
|
|
189
210
|
class GitMonitor:
|
|
190
211
|
"""
|
|
191
212
|
Polls the Git repository for HEAD changes and triggers updates.
|
|
192
213
|
"""
|
|
193
|
-
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
path: Path,
|
|
218
|
+
on_head_change: Callable[[str], Awaitable[None]],
|
|
219
|
+
poll_interval: float = 2.0,
|
|
220
|
+
):
|
|
194
221
|
self.path = path
|
|
195
222
|
self.on_head_change = on_head_change
|
|
196
223
|
self.poll_interval = poll_interval
|
|
@@ -200,10 +227,12 @@ class GitMonitor:
|
|
|
200
227
|
async def get_head_hash(self) -> Optional[str]:
|
|
201
228
|
try:
|
|
202
229
|
process = await asyncio.create_subprocess_exec(
|
|
203
|
-
"git",
|
|
230
|
+
"git",
|
|
231
|
+
"rev-parse",
|
|
232
|
+
"HEAD",
|
|
204
233
|
cwd=self.path,
|
|
205
234
|
stdout=asyncio.subprocess.PIPE,
|
|
206
|
-
stderr=asyncio.subprocess.PIPE
|
|
235
|
+
stderr=asyncio.subprocess.PIPE,
|
|
207
236
|
)
|
|
208
237
|
stdout, _ = await process.communicate()
|
|
209
238
|
if process.returncode == 0:
|
|
@@ -216,15 +245,17 @@ class GitMonitor:
|
|
|
216
245
|
async def start(self):
|
|
217
246
|
self.is_running = True
|
|
218
247
|
logger.info(f"Git Monitor started for {self.path}.")
|
|
219
|
-
|
|
248
|
+
|
|
220
249
|
self.last_head_hash = await self.get_head_hash()
|
|
221
|
-
|
|
250
|
+
|
|
222
251
|
while self.is_running:
|
|
223
252
|
await asyncio.sleep(self.poll_interval)
|
|
224
253
|
current_hash = await self.get_head_hash()
|
|
225
|
-
|
|
254
|
+
|
|
226
255
|
if current_hash and current_hash != self.last_head_hash:
|
|
227
|
-
logger.info(
|
|
256
|
+
logger.info(
|
|
257
|
+
f"Git HEAD changed: {self.last_head_hash} -> {current_hash}"
|
|
258
|
+
)
|
|
228
259
|
self.last_head_hash = current_hash
|
|
229
260
|
await self.on_head_change(current_hash)
|
|
230
261
|
|
monoco/core/hooks.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def install_hooks(project_root: Path, hooks: Dict[str, str]):
|
|
10
|
+
"""
|
|
11
|
+
Install git hooks based on configuration.
|
|
12
|
+
"""
|
|
13
|
+
git_dir = project_root / ".git"
|
|
14
|
+
if not git_dir.exists():
|
|
15
|
+
console.print("[dim]Skipping hooks installation: Not a git repository.[/dim]")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
hooks_dir = git_dir / "hooks"
|
|
19
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
20
|
+
|
|
21
|
+
for hook_name, command in hooks.items():
|
|
22
|
+
hook_path = hooks_dir / hook_name
|
|
23
|
+
|
|
24
|
+
# Check if exists
|
|
25
|
+
if hook_path.exists():
|
|
26
|
+
# Check if it was generated by us
|
|
27
|
+
try:
|
|
28
|
+
with open(hook_path, "r") as f:
|
|
29
|
+
first_line = f.readline()
|
|
30
|
+
if "Monoco Hook" not in first_line:
|
|
31
|
+
console.print(
|
|
32
|
+
f"[yellow]Warning: Hook '{hook_name}' already exists and is not managed by Monoco. Skipping.[/yellow]"
|
|
33
|
+
)
|
|
34
|
+
continue
|
|
35
|
+
# If it IS managed by us, we overwrite it to update the command
|
|
36
|
+
except Exception:
|
|
37
|
+
console.print(
|
|
38
|
+
f"[yellow]Warning: Hook '{hook_name}' exists and cannot be read. Skipping.[/yellow]"
|
|
39
|
+
)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
content = f"""#!/bin/sh
|
|
43
|
+
# Monoco Hook: {hook_name}
|
|
44
|
+
# Auto-generated by Monoco Toolkit. Do not edit manually.
|
|
45
|
+
|
|
46
|
+
{command}
|
|
47
|
+
exit $?
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
with open(hook_path, "w") as f:
|
|
51
|
+
f.write(content)
|
|
52
|
+
|
|
53
|
+
# Make executable
|
|
54
|
+
os.chmod(hook_path, 0o755)
|
|
55
|
+
console.print(f"[green]✓ Installed hook '{hook_name}'[/green]")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
console.print(f"[red]Failed to install hook '{hook_name}': {e}[/red]")
|
monoco/core/injection.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Dict
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
class PromptInjector:
|
|
6
7
|
"""
|
|
@@ -9,17 +10,17 @@ class PromptInjector:
|
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
MANAGED_HEADER = "## Monoco Toolkit"
|
|
12
|
-
|
|
13
|
+
|
|
13
14
|
def __init__(self, target_file: Path):
|
|
14
15
|
self.target_file = target_file
|
|
15
16
|
|
|
16
17
|
def inject(self, prompts: Dict[str, str]) -> bool:
|
|
17
18
|
"""
|
|
18
19
|
Injects the provided prompts into the target file.
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
Args:
|
|
21
22
|
prompts: A dictionary where key is the section title and value is the content.
|
|
22
|
-
|
|
23
|
+
|
|
23
24
|
Returns:
|
|
24
25
|
True if changes were written, False otherwise.
|
|
25
26
|
"""
|
|
@@ -28,7 +29,7 @@ class PromptInjector:
|
|
|
28
29
|
current_content = self.target_file.read_text(encoding="utf-8")
|
|
29
30
|
|
|
30
31
|
new_content = self._merge_content(current_content, prompts)
|
|
31
|
-
|
|
32
|
+
|
|
32
33
|
if new_content != current_content:
|
|
33
34
|
self.target_file.write_text(new_content, encoding="utf-8")
|
|
34
35
|
return True
|
|
@@ -40,24 +41,26 @@ class PromptInjector:
|
|
|
40
41
|
"""
|
|
41
42
|
# 1. Generate the new managed block content
|
|
42
43
|
managed_block = [self.MANAGED_HEADER, ""]
|
|
43
|
-
managed_block.append(
|
|
44
|
-
|
|
44
|
+
managed_block.append(
|
|
45
|
+
"> **Auto-Generated**: This section is managed by Monoco. Do not edit manually.\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
45
48
|
for title, content in prompts.items():
|
|
46
49
|
managed_block.append(f"### {title}")
|
|
47
|
-
managed_block.append("")
|
|
48
|
-
|
|
50
|
+
managed_block.append("") # Blank line after header
|
|
51
|
+
|
|
49
52
|
# Sanitize content: remove leading header if it matches the title
|
|
50
53
|
clean_content = content.strip()
|
|
51
54
|
# Regex to match optional leading hash header matching the title (case insensitive)
|
|
52
55
|
# e.g. "### Issue Management" or "# Issue Management"
|
|
53
56
|
pattern = r"^(#+\s*)" + re.escape(title) + r"\s*\n"
|
|
54
57
|
match = re.match(pattern, clean_content, re.IGNORECASE)
|
|
55
|
-
|
|
58
|
+
|
|
56
59
|
if match:
|
|
57
|
-
clean_content = clean_content[match.end():].strip()
|
|
58
|
-
|
|
60
|
+
clean_content = clean_content[match.end() :].strip()
|
|
61
|
+
|
|
59
62
|
managed_block.append(clean_content)
|
|
60
|
-
managed_block.append("")
|
|
63
|
+
managed_block.append("") # Blank line after section
|
|
61
64
|
|
|
62
65
|
managed_block_str = "\n".join(managed_block).strip() + "\n"
|
|
63
66
|
|
|
@@ -65,13 +68,13 @@ class PromptInjector:
|
|
|
65
68
|
lines = original.splitlines()
|
|
66
69
|
start_idx = -1
|
|
67
70
|
end_idx = -1
|
|
68
|
-
|
|
71
|
+
|
|
69
72
|
# Find start
|
|
70
73
|
for i, line in enumerate(lines):
|
|
71
74
|
if line.strip() == self.MANAGED_HEADER:
|
|
72
75
|
start_idx = i
|
|
73
76
|
break
|
|
74
|
-
|
|
77
|
+
|
|
75
78
|
if start_idx == -1:
|
|
76
79
|
# Block not found, append to end
|
|
77
80
|
if original and not original.endswith("\n"):
|
|
@@ -85,44 +88,44 @@ class PromptInjector:
|
|
|
85
88
|
# Or EOF
|
|
86
89
|
# Note: If MANAGED_HEADER is "# ...", we look for next "# ..."
|
|
87
90
|
# But allow "## ..." as children.
|
|
88
|
-
|
|
91
|
+
|
|
89
92
|
header_level_match = re.match(r"^(#+)\s", self.MANAGED_HEADER)
|
|
90
93
|
header_level_prefix = header_level_match.group(1) if header_level_match else "#"
|
|
91
|
-
|
|
94
|
+
|
|
92
95
|
for i in range(start_idx + 1, len(lines)):
|
|
93
96
|
line = lines[i]
|
|
94
97
|
# Check if this line is a header of the same level or higher (fewer #s)
|
|
95
98
|
# e.g. if Managed is "###", then "#" and "##" are higher/parents, "###" is sibling.
|
|
96
99
|
# We treat siblings as end of block too.
|
|
97
100
|
if line.startswith("#"):
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
# Match regex to get level
|
|
102
|
+
match = re.match(r"^(#+)\s", line)
|
|
103
|
+
if match:
|
|
104
|
+
level = match.group(1)
|
|
105
|
+
if len(level) <= len(header_level_prefix):
|
|
106
|
+
end_idx = i
|
|
107
|
+
break
|
|
108
|
+
|
|
106
109
|
if end_idx == -1:
|
|
107
110
|
end_idx = len(lines)
|
|
108
111
|
|
|
109
112
|
# 3. Construct result
|
|
110
113
|
pre_block = "\n".join(lines[:start_idx])
|
|
111
114
|
post_block = "\n".join(lines[end_idx:])
|
|
112
|
-
|
|
115
|
+
|
|
113
116
|
result = pre_block
|
|
114
117
|
if result:
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
result += "\n\n"
|
|
119
|
+
|
|
117
120
|
result += managed_block_str
|
|
118
|
-
|
|
121
|
+
|
|
119
122
|
if post_block:
|
|
120
123
|
# Ensure separation if post block exists and isn't just empty lines
|
|
121
124
|
if post_block.strip():
|
|
122
125
|
result += "\n" + post_block
|
|
123
126
|
else:
|
|
124
|
-
result += post_block
|
|
125
|
-
|
|
127
|
+
result += post_block # Keep trailing newlines if any, or normalize?
|
|
128
|
+
|
|
126
129
|
return result.strip() + "\n"
|
|
127
130
|
|
|
128
131
|
def remove(self) -> bool:
|
|
@@ -157,23 +160,23 @@ class PromptInjector:
|
|
|
157
160
|
for i in range(start_idx + 1, len(lines)):
|
|
158
161
|
line = lines[i]
|
|
159
162
|
if line.startswith("#"):
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
match = re.match(r"^(#+)\s", line)
|
|
164
|
+
if match:
|
|
165
|
+
level = match.group(1)
|
|
166
|
+
if len(level) <= len(header_level_prefix):
|
|
167
|
+
end_idx = i
|
|
168
|
+
break
|
|
169
|
+
|
|
167
170
|
if end_idx == -1:
|
|
168
171
|
end_idx = len(lines)
|
|
169
172
|
|
|
170
173
|
# Reconstruct content without the block
|
|
171
174
|
# We also need to be careful about surrounding newlines to avoid leaving gaps
|
|
172
|
-
|
|
175
|
+
|
|
173
176
|
# Check lines before start_idx
|
|
174
|
-
while start_idx > 0 and not lines[start_idx-1].strip():
|
|
177
|
+
while start_idx > 0 and not lines[start_idx - 1].strip():
|
|
175
178
|
start_idx -= 1
|
|
176
|
-
|
|
179
|
+
|
|
177
180
|
# Check lines after end_idx (optional, but good for cleanup)
|
|
178
181
|
# Usually end_idx points to the next header or EOF.
|
|
179
182
|
# If it points to next header, we keep it.
|
|
@@ -182,12 +185,12 @@ class PromptInjector:
|
|
|
182
185
|
post_block = lines[end_idx:]
|
|
183
186
|
|
|
184
187
|
# If we removed everything, the file might become empty or just newlines
|
|
185
|
-
|
|
188
|
+
|
|
186
189
|
new_lines = pre_block + post_block
|
|
187
190
|
if not new_lines:
|
|
188
|
-
|
|
191
|
+
new_content = ""
|
|
189
192
|
else:
|
|
190
|
-
|
|
193
|
+
new_content = "\n".join(new_lines).strip() + "\n"
|
|
191
194
|
|
|
192
195
|
if new_content != current_content:
|
|
193
196
|
self.target_file.write_text(new_content, encoding="utf-8")
|