nexo-brain 3.0.2 → 3.1.0
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +62 -0
- package/community/launch/2026-04-v3-0-2/case-study-outreach.md +36 -0
- package/community/launch/2026-04-v3-0-2/devto-v3-0-2.md +91 -0
- package/community/launch/2026-04-v3-0-2/github-discussion-v3-0-2.md +58 -0
- package/community/launch/2026-04-v3-0-2/x-thread-v3-0-2.md +60 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/src/client_sync.py +6 -0
- package/src/doctor/providers/runtime.py +10 -5
- package/src/evolution_cycle.py +28 -1
- package/src/hook_guardrails.py +182 -0
- package/src/hooks/protocol-guardrail.sh +1 -1
- package/src/hooks/protocol-pretool-guardrail.sh +9 -0
- package/src/plugins/protocol.py +15 -0
- package/src/protocol_settings.py +59 -0
- package/src/public_evolution_queue.py +241 -0
- package/src/scripts/nexo-daily-self-audit.py +665 -27
- package/src/scripts/nexo-evolution-run.py +35 -1
- package/src/server.py +26 -1
package/src/hook_guardrails.py
CHANGED
|
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
"""Post-tool guardrails for conditioned file learnings."""
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from db import create_protocol_debt, get_db
|
|
10
11
|
from plugins.guard import _load_conditioned_learnings, _normalize_path_token
|
|
12
|
+
from protocol_settings import get_protocol_strictness
|
|
11
13
|
|
|
12
14
|
READ_LIKE_TOOLS = {"Read"}
|
|
13
15
|
WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
|
|
@@ -94,6 +96,18 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
|
|
|
94
96
|
return None
|
|
95
97
|
|
|
96
98
|
|
|
99
|
+
def _find_any_open_task(conn, sid: str) -> dict | None:
|
|
100
|
+
row = conn.execute(
|
|
101
|
+
"""SELECT task_id, files, guard_has_blocking
|
|
102
|
+
FROM protocol_tasks
|
|
103
|
+
WHERE session_id = ? AND status = 'open'
|
|
104
|
+
ORDER BY opened_at DESC
|
|
105
|
+
LIMIT 1""",
|
|
106
|
+
(sid,),
|
|
107
|
+
).fetchone()
|
|
108
|
+
return dict(row) if row else None
|
|
109
|
+
|
|
110
|
+
|
|
97
111
|
def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
|
|
98
112
|
row = conn.execute(
|
|
99
113
|
"""SELECT *
|
|
@@ -152,6 +166,136 @@ def _ensure_protocol_debt(
|
|
|
152
166
|
)
|
|
153
167
|
|
|
154
168
|
|
|
169
|
+
def process_pre_tool_event(payload: dict) -> dict:
|
|
170
|
+
strictness = get_protocol_strictness()
|
|
171
|
+
tool_name = str(payload.get("tool_name", "")).strip()
|
|
172
|
+
op = _operation_kind(tool_name)
|
|
173
|
+
if strictness == "lenient":
|
|
174
|
+
return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
|
|
175
|
+
if op not in {"write", "delete"}:
|
|
176
|
+
return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": strictness}
|
|
177
|
+
|
|
178
|
+
tool_input = payload.get("tool_input")
|
|
179
|
+
files = _extract_touched_files(tool_input)
|
|
180
|
+
conn = get_db()
|
|
181
|
+
sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
|
|
182
|
+
blocks: list[dict] = []
|
|
183
|
+
|
|
184
|
+
if not sid:
|
|
185
|
+
debt = _ensure_protocol_debt(
|
|
186
|
+
conn,
|
|
187
|
+
session_id="",
|
|
188
|
+
task_id="",
|
|
189
|
+
debt_type="strict_protocol_write_without_startup",
|
|
190
|
+
severity="error",
|
|
191
|
+
evidence=f"{tool_name} attempted before nexo_startup/session mapping.",
|
|
192
|
+
file_token="startup",
|
|
193
|
+
)
|
|
194
|
+
blocks.append(
|
|
195
|
+
{
|
|
196
|
+
"file": "",
|
|
197
|
+
"task_id": "",
|
|
198
|
+
"debt_id": debt.get("id"),
|
|
199
|
+
"debt_type": "strict_protocol_write_without_startup",
|
|
200
|
+
"reason_code": "missing_startup",
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
"ok": True,
|
|
205
|
+
"session_id": sid,
|
|
206
|
+
"tool_name": tool_name,
|
|
207
|
+
"operation": op,
|
|
208
|
+
"strictness": strictness,
|
|
209
|
+
"blocks": blocks,
|
|
210
|
+
"status": "blocked",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if not files:
|
|
214
|
+
task = _find_any_open_task(conn, sid)
|
|
215
|
+
if not task:
|
|
216
|
+
debt = _ensure_protocol_debt(
|
|
217
|
+
conn,
|
|
218
|
+
session_id=sid,
|
|
219
|
+
task_id="",
|
|
220
|
+
debt_type="strict_protocol_write_without_task",
|
|
221
|
+
severity="error",
|
|
222
|
+
evidence=f"{tool_name} attempted without a detectable file path and without an open protocol task.",
|
|
223
|
+
file_token="unknown-target",
|
|
224
|
+
)
|
|
225
|
+
blocks.append(
|
|
226
|
+
{
|
|
227
|
+
"file": "",
|
|
228
|
+
"task_id": "",
|
|
229
|
+
"debt_id": debt.get("id"),
|
|
230
|
+
"debt_type": "strict_protocol_write_without_task",
|
|
231
|
+
"reason_code": "missing_task",
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return {
|
|
235
|
+
"ok": True,
|
|
236
|
+
"session_id": sid,
|
|
237
|
+
"tool_name": tool_name,
|
|
238
|
+
"operation": op,
|
|
239
|
+
"strictness": strictness,
|
|
240
|
+
"blocks": blocks,
|
|
241
|
+
"status": "blocked" if blocks else "clean",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for filepath in files:
|
|
245
|
+
task = _find_open_task_for_file(conn, sid, filepath)
|
|
246
|
+
if not task:
|
|
247
|
+
debt = _ensure_protocol_debt(
|
|
248
|
+
conn,
|
|
249
|
+
session_id=sid,
|
|
250
|
+
task_id="",
|
|
251
|
+
debt_type="strict_protocol_write_without_task",
|
|
252
|
+
severity="error",
|
|
253
|
+
evidence=f"{tool_name} attempted on {filepath} without an open protocol task for that file.",
|
|
254
|
+
file_token=filepath,
|
|
255
|
+
)
|
|
256
|
+
blocks.append(
|
|
257
|
+
{
|
|
258
|
+
"file": filepath,
|
|
259
|
+
"task_id": "",
|
|
260
|
+
"debt_id": debt.get("id"),
|
|
261
|
+
"debt_type": "strict_protocol_write_without_task",
|
|
262
|
+
"reason_code": "missing_task",
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
guard_debt = _find_task_guard_blocking_debt(conn, task["task_id"])
|
|
268
|
+
if guard_debt:
|
|
269
|
+
debt = _ensure_protocol_debt(
|
|
270
|
+
conn,
|
|
271
|
+
session_id=sid,
|
|
272
|
+
task_id=task["task_id"],
|
|
273
|
+
debt_type="strict_protocol_write_without_guard_ack",
|
|
274
|
+
severity="error",
|
|
275
|
+
evidence=f"{tool_name} attempted on {filepath} before acknowledging guard debt for task {task['task_id']}.",
|
|
276
|
+
file_token=filepath,
|
|
277
|
+
)
|
|
278
|
+
blocks.append(
|
|
279
|
+
{
|
|
280
|
+
"file": filepath,
|
|
281
|
+
"task_id": task["task_id"],
|
|
282
|
+
"debt_id": debt.get("id"),
|
|
283
|
+
"debt_type": "strict_protocol_write_without_guard_ack",
|
|
284
|
+
"reason_code": "guard_unacknowledged",
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"ok": True,
|
|
290
|
+
"session_id": sid,
|
|
291
|
+
"tool_name": tool_name,
|
|
292
|
+
"operation": op,
|
|
293
|
+
"strictness": strictness,
|
|
294
|
+
"blocks": blocks,
|
|
295
|
+
"status": "blocked" if blocks else "clean",
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
155
299
|
def process_tool_event(payload: dict) -> dict:
|
|
156
300
|
tool_name = str(payload.get("tool_name", "")).strip()
|
|
157
301
|
op = _operation_kind(tool_name)
|
|
@@ -289,6 +433,38 @@ def format_hook_message(result: dict) -> str:
|
|
|
289
433
|
return "\n".join(lines)
|
|
290
434
|
|
|
291
435
|
|
|
436
|
+
def format_pretool_block_message(result: dict) -> str:
|
|
437
|
+
blocks = result.get("blocks") or []
|
|
438
|
+
if not blocks:
|
|
439
|
+
return ""
|
|
440
|
+
strictness = str(result.get("strictness") or "strict")
|
|
441
|
+
header = (
|
|
442
|
+
"NEXO LEARNING MODE BLOCKED THIS EDIT:"
|
|
443
|
+
if strictness == "learning"
|
|
444
|
+
else "NEXO STRICT MODE BLOCKED THIS EDIT:"
|
|
445
|
+
)
|
|
446
|
+
lines = [header]
|
|
447
|
+
for item in blocks:
|
|
448
|
+
file_note = item["file"] or "(unknown target)"
|
|
449
|
+
if item.get("reason_code") == "missing_startup":
|
|
450
|
+
lines.append(
|
|
451
|
+
f"- Start the shared-brain session first: call `nexo_startup`, then `nexo_task_open`, before editing {file_note}."
|
|
452
|
+
)
|
|
453
|
+
elif item.get("reason_code") == "guard_unacknowledged":
|
|
454
|
+
lines.append(
|
|
455
|
+
f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
|
|
456
|
+
)
|
|
457
|
+
elif strictness == "learning":
|
|
458
|
+
lines.append(
|
|
459
|
+
f"- {file_note}: open `nexo_task_open(task_type='edit', files=['{file_note}'])` first, then rerun the edit."
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
lines.append(
|
|
463
|
+
f"- {file_note}: open `nexo_task_open(... files=['{file_note}'])` before editing."
|
|
464
|
+
)
|
|
465
|
+
return "\n".join(lines)
|
|
466
|
+
|
|
467
|
+
|
|
292
468
|
def main() -> int:
|
|
293
469
|
raw = sys.stdin.read()
|
|
294
470
|
if not raw.strip():
|
|
@@ -297,6 +473,12 @@ def main() -> int:
|
|
|
297
473
|
payload = json.loads(raw)
|
|
298
474
|
except Exception:
|
|
299
475
|
return 0
|
|
476
|
+
if os.environ.get("NEXO_HOOK_PHASE", "").strip().lower() == "pre":
|
|
477
|
+
result = process_pre_tool_event(payload)
|
|
478
|
+
message = format_pretool_block_message(result)
|
|
479
|
+
if message:
|
|
480
|
+
print(message, file=sys.stderr)
|
|
481
|
+
return 2 if result.get("status") == "blocked" else 0
|
|
300
482
|
result = process_tool_event(payload)
|
|
301
483
|
message = format_hook_message(result)
|
|
302
484
|
if message:
|
|
@@ -5,6 +5,6 @@ INPUT=$(cat || true)
|
|
|
5
5
|
[ -z "$INPUT" ] && exit 0
|
|
6
6
|
|
|
7
7
|
NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
|
|
8
|
-
python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
|
|
8
|
+
NEXO_HOOK_PHASE=post python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
|
|
9
9
|
|
|
10
10
|
exit 0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# NEXO PreToolUse hook — strict protocol blocking before writes/deletes
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat || true)
|
|
5
|
+
[ -z "$INPUT" ] && exit 0
|
|
6
|
+
|
|
7
|
+
NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
|
|
8
|
+
NEXO_HOOK_PHASE=pre python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT"
|
|
9
|
+
exit $?
|
package/src/plugins/protocol.py
CHANGED
|
@@ -23,6 +23,7 @@ from db import (
|
|
|
23
23
|
)
|
|
24
24
|
from plugins.cortex import evaluate_cortex_state
|
|
25
25
|
from plugins.guard import handle_guard_check
|
|
26
|
+
from protocol_settings import get_protocol_strictness
|
|
26
27
|
from tools_sessions import handle_heartbeat
|
|
27
28
|
|
|
28
29
|
|
|
@@ -450,6 +451,18 @@ def handle_task_open(
|
|
|
450
451
|
|
|
451
452
|
clean_type = task_type if task_type in {"answer", "analyze", "edit", "execute", "delegate"} else "answer"
|
|
452
453
|
files_list = _parse_list(files)
|
|
454
|
+
protocol_strictness = get_protocol_strictness()
|
|
455
|
+
if protocol_strictness in {"strict", "learning"} and clean_type == "edit" and not files_list:
|
|
456
|
+
note = (
|
|
457
|
+
"Strict protocol mode requires explicit `files` for edit tasks."
|
|
458
|
+
if protocol_strictness == "strict"
|
|
459
|
+
else "Learning mode requires explicit `files` on edit tasks so NEXO can match the write against the open protocol task."
|
|
460
|
+
)
|
|
461
|
+
return json.dumps(
|
|
462
|
+
{"ok": False, "error": note, "protocol_strictness": protocol_strictness},
|
|
463
|
+
ensure_ascii=False,
|
|
464
|
+
indent=2,
|
|
465
|
+
)
|
|
453
466
|
state = {
|
|
454
467
|
"goal": clean_goal,
|
|
455
468
|
"task_type": clean_type,
|
|
@@ -569,6 +582,7 @@ def handle_task_open(
|
|
|
569
582
|
"session_id": sid,
|
|
570
583
|
"goal": clean_goal,
|
|
571
584
|
"task_type": clean_type,
|
|
585
|
+
"protocol_strictness": protocol_strictness,
|
|
572
586
|
"mode": cortex["mode"],
|
|
573
587
|
"check_id": cortex["check_id"],
|
|
574
588
|
"blocked_reason": cortex.get("blocked_reason"),
|
|
@@ -596,6 +610,7 @@ def handle_task_open(
|
|
|
596
610
|
"must_change_log": must_change_log,
|
|
597
611
|
"must_learning_if_corrected": must_learning_if_corrected,
|
|
598
612
|
"must_write_diary_on_close": must_write_diary_on_close,
|
|
613
|
+
"protocol_strictness": protocol_strictness,
|
|
599
614
|
},
|
|
600
615
|
"session_touch": heartbeat_result.splitlines()[0] if heartbeat_result else "",
|
|
601
616
|
"open_debts": debts_created,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Shared protocol-discipline settings loaded from calibration.json."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_PROTOCOL_STRICTNESS = "lenient"
|
|
11
|
+
VALID_PROTOCOL_STRICTNESS = {"lenient", "strict", "learning"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _nexo_home() -> Path:
|
|
15
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _calibration_path() -> Path:
|
|
19
|
+
return _nexo_home() / "brain" / "calibration.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_protocol_strictness(value: str | None) -> str:
|
|
23
|
+
candidate = str(value or "").strip().lower()
|
|
24
|
+
aliases = {
|
|
25
|
+
"default": "lenient",
|
|
26
|
+
"normal": "lenient",
|
|
27
|
+
"off": "lenient",
|
|
28
|
+
"warn": "lenient",
|
|
29
|
+
"soft": "lenient",
|
|
30
|
+
"hard": "strict",
|
|
31
|
+
"guided": "learning",
|
|
32
|
+
}
|
|
33
|
+
candidate = aliases.get(candidate, candidate)
|
|
34
|
+
if candidate in VALID_PROTOCOL_STRICTNESS:
|
|
35
|
+
return candidate
|
|
36
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_protocol_strictness() -> str:
|
|
40
|
+
env_override = os.environ.get("NEXO_PROTOCOL_STRICTNESS", "").strip()
|
|
41
|
+
if env_override:
|
|
42
|
+
return normalize_protocol_strictness(env_override)
|
|
43
|
+
|
|
44
|
+
cal_path = _calibration_path()
|
|
45
|
+
if not cal_path.is_file():
|
|
46
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(cal_path.read_text())
|
|
50
|
+
except Exception:
|
|
51
|
+
return DEFAULT_PROTOCOL_STRICTNESS
|
|
52
|
+
|
|
53
|
+
preferences = payload.get("preferences") if isinstance(payload, dict) else {}
|
|
54
|
+
candidate = ""
|
|
55
|
+
if isinstance(preferences, dict):
|
|
56
|
+
candidate = str(preferences.get("protocol_strictness", "") or "").strip()
|
|
57
|
+
if not candidate and isinstance(payload, dict):
|
|
58
|
+
candidate = str(payload.get("protocol_strictness", "") or "").strip()
|
|
59
|
+
return normalize_protocol_strictness(candidate)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Durable queue for public-core ports discovered outside the public runner.
|
|
4
|
+
|
|
5
|
+
Managed flows such as self-audit may apply a local/core fix inline. When that
|
|
6
|
+
fix belongs in the public repository as well, we persist a normalized queue
|
|
7
|
+
entry in ``evolution_log`` so the weekly public contribution cycle can port it
|
|
8
|
+
later instead of losing the improvement inside one machine.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
|
|
18
|
+
QUEUE_CLASSIFICATION = "public_port_queue"
|
|
19
|
+
QUEUE_STATUS_PENDING = "pending_public_port"
|
|
20
|
+
PUBLIC_ALLOWED_PREFIXES = (
|
|
21
|
+
"src/",
|
|
22
|
+
"bin/",
|
|
23
|
+
"tests/",
|
|
24
|
+
"templates/",
|
|
25
|
+
"hooks/",
|
|
26
|
+
"migrations/",
|
|
27
|
+
".claude-plugin/",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_repo_root(nexo_code: str | os.PathLike[str] | None = None) -> Path | None:
|
|
32
|
+
raw = Path(
|
|
33
|
+
nexo_code
|
|
34
|
+
or os.environ.get("NEXO_CODE")
|
|
35
|
+
or str(NEXO_HOME)
|
|
36
|
+
).expanduser()
|
|
37
|
+
candidates = []
|
|
38
|
+
if raw.name == "src":
|
|
39
|
+
candidates.append(raw.parent)
|
|
40
|
+
candidates.append(raw)
|
|
41
|
+
for candidate in candidates:
|
|
42
|
+
if (candidate / "package.json").exists():
|
|
43
|
+
return candidate.resolve()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_public_path(
|
|
48
|
+
filepath: str,
|
|
49
|
+
*,
|
|
50
|
+
repo_root: Path | None = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
text = str(filepath or "").strip()
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
normalized_raw = text.replace("\\", "/").lstrip("./")
|
|
57
|
+
if any(
|
|
58
|
+
normalized_raw == prefix.rstrip("/")
|
|
59
|
+
or normalized_raw.startswith(prefix)
|
|
60
|
+
for prefix in PUBLIC_ALLOWED_PREFIXES
|
|
61
|
+
):
|
|
62
|
+
return normalized_raw
|
|
63
|
+
|
|
64
|
+
repo_root = repo_root or resolve_repo_root()
|
|
65
|
+
if not repo_root:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
candidate = Path(text).expanduser()
|
|
69
|
+
if not candidate.is_absolute():
|
|
70
|
+
candidate = repo_root / candidate
|
|
71
|
+
try:
|
|
72
|
+
rel = candidate.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
73
|
+
except Exception:
|
|
74
|
+
for prefix in PUBLIC_ALLOWED_PREFIXES:
|
|
75
|
+
marker = normalized_raw.find(prefix)
|
|
76
|
+
if marker >= 0:
|
|
77
|
+
return normalized_raw[marker:]
|
|
78
|
+
return ""
|
|
79
|
+
if any(rel == prefix.rstrip("/") or rel.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES):
|
|
80
|
+
return rel
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_public_core_path(filepath: str, *, repo_root: Path | None = None) -> bool:
|
|
85
|
+
return bool(normalize_public_path(filepath, repo_root=repo_root))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def queue_public_port_candidate(
|
|
89
|
+
conn: sqlite3.Connection,
|
|
90
|
+
*,
|
|
91
|
+
title: str,
|
|
92
|
+
reasoning: str,
|
|
93
|
+
files_changed: list[str],
|
|
94
|
+
source: str,
|
|
95
|
+
metadata: dict | None = None,
|
|
96
|
+
) -> dict:
|
|
97
|
+
row = conn.execute(
|
|
98
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='evolution_log'"
|
|
99
|
+
).fetchone()
|
|
100
|
+
if not row:
|
|
101
|
+
return {"ok": False, "reason": "evolution_log_missing"}
|
|
102
|
+
|
|
103
|
+
repo_root = resolve_repo_root()
|
|
104
|
+
normalized_files: list[str] = []
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for filepath in files_changed:
|
|
107
|
+
rel = normalize_public_path(filepath, repo_root=repo_root)
|
|
108
|
+
if rel and rel not in seen:
|
|
109
|
+
normalized_files.append(rel)
|
|
110
|
+
seen.add(rel)
|
|
111
|
+
if not normalized_files:
|
|
112
|
+
return {"ok": False, "reason": "no_public_files"}
|
|
113
|
+
|
|
114
|
+
proposal = str(title or "").strip()[:300] or "Managed core autofix queued for public port"
|
|
115
|
+
clean_reasoning = str(reasoning or "").strip()[:4000] or "Queued for public-core port."
|
|
116
|
+
payload = dict(metadata or {})
|
|
117
|
+
payload.setdefault("source", source)
|
|
118
|
+
payload["files"] = normalized_files
|
|
119
|
+
|
|
120
|
+
existing = conn.execute(
|
|
121
|
+
"""SELECT id, status
|
|
122
|
+
FROM evolution_log
|
|
123
|
+
WHERE classification = ?
|
|
124
|
+
AND proposal = ?
|
|
125
|
+
AND files_changed = ?
|
|
126
|
+
AND status IN (?, 'draft_pr_created', 'skipped_duplicate_existing_pr')
|
|
127
|
+
ORDER BY id DESC
|
|
128
|
+
LIMIT 1""",
|
|
129
|
+
(
|
|
130
|
+
QUEUE_CLASSIFICATION,
|
|
131
|
+
proposal,
|
|
132
|
+
json.dumps(normalized_files),
|
|
133
|
+
QUEUE_STATUS_PENDING,
|
|
134
|
+
),
|
|
135
|
+
).fetchone()
|
|
136
|
+
if existing:
|
|
137
|
+
return {
|
|
138
|
+
"ok": True,
|
|
139
|
+
"queued": False,
|
|
140
|
+
"log_id": int(existing["id"]),
|
|
141
|
+
"status": str(existing["status"] or ""),
|
|
142
|
+
"files_changed": normalized_files,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cur = conn.execute(
|
|
146
|
+
"""INSERT INTO evolution_log (
|
|
147
|
+
cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result
|
|
148
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
149
|
+
(
|
|
150
|
+
0,
|
|
151
|
+
"public_core",
|
|
152
|
+
proposal,
|
|
153
|
+
QUEUE_CLASSIFICATION,
|
|
154
|
+
clean_reasoning,
|
|
155
|
+
QUEUE_STATUS_PENDING,
|
|
156
|
+
json.dumps(normalized_files),
|
|
157
|
+
json.dumps(payload, ensure_ascii=False),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return {
|
|
161
|
+
"ok": True,
|
|
162
|
+
"queued": True,
|
|
163
|
+
"log_id": int(cur.lastrowid),
|
|
164
|
+
"status": QUEUE_STATUS_PENDING,
|
|
165
|
+
"files_changed": normalized_files,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_pending_public_port_candidates(
|
|
170
|
+
conn: sqlite3.Connection,
|
|
171
|
+
*,
|
|
172
|
+
limit: int = 3,
|
|
173
|
+
) -> list[dict]:
|
|
174
|
+
rows = conn.execute(
|
|
175
|
+
"""SELECT id, created_at, proposal, reasoning, status, files_changed, test_result
|
|
176
|
+
FROM evolution_log
|
|
177
|
+
WHERE classification = ?
|
|
178
|
+
AND status = ?
|
|
179
|
+
ORDER BY created_at ASC, id ASC
|
|
180
|
+
LIMIT ?""",
|
|
181
|
+
(QUEUE_CLASSIFICATION, QUEUE_STATUS_PENDING, max(1, int(limit))),
|
|
182
|
+
).fetchall()
|
|
183
|
+
results: list[dict] = []
|
|
184
|
+
for row in rows:
|
|
185
|
+
metadata = {}
|
|
186
|
+
raw_payload = str(row["test_result"] or "").strip()
|
|
187
|
+
if raw_payload:
|
|
188
|
+
try:
|
|
189
|
+
parsed = json.loads(raw_payload)
|
|
190
|
+
if isinstance(parsed, dict):
|
|
191
|
+
metadata = parsed
|
|
192
|
+
except Exception:
|
|
193
|
+
metadata = {"raw": raw_payload}
|
|
194
|
+
files_changed = []
|
|
195
|
+
raw_files = str(row["files_changed"] or "").strip()
|
|
196
|
+
if raw_files:
|
|
197
|
+
try:
|
|
198
|
+
parsed_files = json.loads(raw_files)
|
|
199
|
+
if isinstance(parsed_files, list):
|
|
200
|
+
files_changed = [str(item).strip() for item in parsed_files if str(item).strip()]
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
results.append(
|
|
204
|
+
{
|
|
205
|
+
"id": int(row["id"]),
|
|
206
|
+
"created_at": str(row["created_at"] or ""),
|
|
207
|
+
"title": str(row["proposal"] or ""),
|
|
208
|
+
"reasoning": str(row["reasoning"] or ""),
|
|
209
|
+
"status": str(row["status"] or ""),
|
|
210
|
+
"files_changed": files_changed,
|
|
211
|
+
"metadata": metadata,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
return results
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def update_public_port_candidate(
|
|
218
|
+
conn: sqlite3.Connection,
|
|
219
|
+
log_id: int,
|
|
220
|
+
*,
|
|
221
|
+
status: str,
|
|
222
|
+
metadata_patch: dict | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
row = conn.execute(
|
|
225
|
+
"SELECT test_result FROM evolution_log WHERE id = ? LIMIT 1",
|
|
226
|
+
(int(log_id),),
|
|
227
|
+
).fetchone()
|
|
228
|
+
payload: dict = {}
|
|
229
|
+
if row and str(row["test_result"] or "").strip():
|
|
230
|
+
try:
|
|
231
|
+
parsed = json.loads(str(row["test_result"]))
|
|
232
|
+
if isinstance(parsed, dict):
|
|
233
|
+
payload = parsed
|
|
234
|
+
except Exception:
|
|
235
|
+
payload = {"raw": str(row["test_result"])}
|
|
236
|
+
if metadata_patch:
|
|
237
|
+
payload.update(metadata_patch)
|
|
238
|
+
conn.execute(
|
|
239
|
+
"UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
|
|
240
|
+
(status, json.dumps(payload, ensure_ascii=False), int(log_id)),
|
|
241
|
+
)
|