feat: audit log + model cmd + indent alignment fix

Audit log (audit/logger.py):
- Persists task execution trace as human-readable JSON
- Storage: ~/.kaiwu/logs/, max 100, auto-cleanup
- Records: task, gate, experts, files, tests, retries (no code content)
- orchestrator._emit() now instance method, auto-logs to audit
- CLI: kwcode log / log show <id> / log clear

Model commands (cli/commands/model_cmd.py):
- kwcode model: show current config + tier
- kwcode model set <name>: switch model (writes config.yaml)
- kwcode model probe: detect family/params/quant/reasoning via Ollama API

Indent alignment fix (Generator._align_indentation):
- Fixes systematic bug: LLM returns class methods at 0-indent,
  apply_patch replaces 4-indent original → method escapes class
- Aligns modified base indent to match original before apply_patch

Tests: 10 new (audit 4 + indent 5 + hashline prompt 1), 50 total green

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Val-sss
2026-05-06 20:13:26 +08:00
parent 129eed76d0
commit 7b61af7be3
9 changed files with 646 additions and 3 deletions

View File

@@ -53,6 +53,24 @@
- orchestrator._record_success/_record_failure_result: 调_record_flywheel()
- _record_flywheel(): 策略统计 + 用户模式 + 遥测,三路全非阻塞
**审计日志** (`audit/logger.py`)
- AuditLogger: start() → log(stage, detail) → write(ctx, elapsed, success, model)
- 存储:~/.kaiwu/logs/YYYY-MM-DD_HHMMSS_<expert_type>.json
- 不记录代码内容,只记录:任务描述/Gate分类/专家执行时间/文件名/测试结果/重试次数
- 最多保留100条超出自动清理
- orchestrator._emit()从@staticmethod改为实例方法每个事件自动记录到audit
- CLI: `kwcode log` / `kwcode log show <id>` / `kwcode log clear`
**kwcode model命令** (`cli/commands/model_cmd.py`)
- `kwcode model` — 显示当前模型配置+能力tier
- `kwcode model set <名称>` — 切换模型写入config.yaml
- `kwcode model probe` — 探测模型详情Ollama API: family/参数量/量化/reasoning
**缩进对齐修复** (`Generator._align_indentation`)
- 修复系统性bugLLM返回class方法时丢失缩进0空格 vs 原始4空格
- apply_patch替换后方法"跑出"class导致IndentationError
- 修法_generate_modified()返回后立刻调_align_indentation()补齐缩进差
**P0: Hashline锚点编辑** (`tools/hashline.py`)
- add_anchors(): 每行加6字符MD5哈希锚点 `行号|哈希| 内容`
- parse_anchor_edits(): 解析 EDIT/DELETE/INSERT_AFTER 指令

0
kaiwu/audit/__init__.py Normal file
View File

180
kaiwu/audit/logger.py Normal file
View File

@@ -0,0 +1,180 @@
"""
审计日志:持久化任务执行轨迹为人类可读格式。
存储位置:.kaiwu/logs/YYYY-MM-DD_HHMMSS_<expert_type>.json
不记录代码内容,只记录元数据和行为轨迹。
最多保留100条超出自动清理最旧的。
"""
import json
import logging
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
LOGS_DIR = Path.home() / ".kaiwu" / "logs"
MAX_LOGS = 100
class AuditLogger:
"""任务执行审计日志。"""
def __init__(self):
self._events: list[dict] = []
self._start_time: float = 0
def start(self):
"""任务开始时调用。"""
self._events = []
self._start_time = time.time()
def log(self, stage: str, detail: str):
"""记录一个执行事件。"""
elapsed = time.time() - self._start_time if self._start_time else 0
self._events.append({
"time": datetime.now().strftime("%H:%M:%S"),
"elapsed_s": round(elapsed, 1),
"stage": stage,
"detail": detail,
})
def write(self, ctx, elapsed: float, success: bool, model: str = "unknown"):
"""
任务完成时写入日志文件。非阻塞,失败静默。
Args:
ctx: TaskContext
elapsed: 总耗时秒数
success: 是否成功
model: 模型名称
"""
try:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
gate = ctx.gate_result or {}
expert_type = gate.get("expert_type", "unknown")
difficulty = gate.get("difficulty", "?")
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
filename = f"{ts}_{expert_type}.json"
# 提取修改的文件列表(不含内容)
files_modified = []
patches = []
if ctx.generator_output:
patches = ctx.generator_output.get("patches", [])
files_modified = [p.get("file", "") for p in patches]
# 计算改动行数
lines_added = 0
lines_removed = 0
for p in patches:
orig_lines = len(p.get("original", "").split("\n")) if p.get("original") else 0
mod_lines = len(p.get("modified", "").split("\n")) if p.get("modified") else 0
lines_added += max(0, mod_lines - orig_lines)
lines_removed += max(0, orig_lines - mod_lines)
# 测试结果
tests_passed = 0
tests_total = 0
if ctx.verifier_output:
tests_passed = ctx.verifier_output.get("tests_passed", 0)
tests_total = ctx.verifier_output.get("tests_total", 0)
record = {
"task": ctx.user_input[:200], # 保留任务描述(用户自己的输入)
"timestamp": datetime.now().isoformat(),
"model": model,
"expert_type": expert_type,
"difficulty": difficulty,
"elapsed_s": round(elapsed, 1),
"success": success,
"retry_count": ctx.retry_count,
"files_modified": files_modified,
"lines_added": lines_added,
"lines_removed": lines_removed,
"tests_passed": tests_passed,
"tests_total": tests_total,
"search_triggered": ctx.search_triggered,
"events": self._events,
}
log_path = LOGS_DIR / filename
log_path.write_text(
json.dumps(record, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# 清理超过MAX_LOGS的旧日志
self._cleanup()
except Exception as e:
logger.debug("Audit log write failed (non-blocking): %s", e)
def _cleanup(self):
"""保留最近MAX_LOGS条日志删除最旧的。"""
try:
logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name)
if len(logs) > MAX_LOGS:
for old in logs[:len(logs) - MAX_LOGS]:
old.unlink()
except Exception:
pass
def list_logs(limit: int = 20) -> list[dict]:
"""列出最近的日志摘要。"""
if not LOGS_DIR.exists():
return []
logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name, reverse=True)
result = []
for i, path in enumerate(logs[:limit]):
try:
data = json.loads(path.read_text(encoding="utf-8"))
result.append({
"id": len(logs) - i,
"file": path.name,
"task": data.get("task", "")[:60],
"success": data.get("success", False),
"elapsed_s": data.get("elapsed_s", 0),
"timestamp": data.get("timestamp", ""),
"model": data.get("model", ""),
})
except Exception:
continue
return result
def show_log(log_id: int) -> Optional[dict]:
"""获取指定ID的日志详情。"""
if not LOGS_DIR.exists():
return None
logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name)
idx = log_id - 1
if idx < 0 or idx >= len(logs):
return None
try:
return json.loads(logs[idx].read_text(encoding="utf-8"))
except Exception:
return None
def clear_logs() -> int:
"""清除所有日志,返回删除数量。"""
if not LOGS_DIR.exists():
return 0
count = 0
for path in LOGS_DIR.glob("*.json"):
try:
path.unlink()
count += 1
except Exception:
pass
return count

View File

@@ -0,0 +1,108 @@
"""
kwcode log — 任务历史审计日志命令。
"""
import typer
from rich.prompt import Confirm
from kaiwu.cli.formatters import console
log_app = typer.Typer(name="log", help="任务历史日志")
@log_app.callback(invoke_without_command=True)
def log_list(
ctx: typer.Context,
limit: int = typer.Option(20, "--limit", "-n", help="显示条数"),
):
"""查看最近的任务历史。"""
if ctx.invoked_subcommand is not None:
return
from kaiwu.audit.logger import list_logs
logs = list_logs(limit=limit)
if not logs:
console.print(" [dim]暂无任务记录[/dim]")
return
console.print()
console.print(" [bold]任务历史[/bold]")
console.print(" " + "" * 55)
for entry in logs:
icon = "[green]✅[/green]" if entry["success"] else "[red]❌[/red]"
elapsed = f"{entry['elapsed_s']:.0f}s"
task = entry["task"][:40]
ts = entry["timestamp"][:16].replace("T", " ") if entry["timestamp"] else ""
console.print(
f" #{entry['id']:<3} {icon} {elapsed:>4} {task:<40} [dim]{ts}[/dim]"
)
console.print()
console.print(" [dim]查看详情kwcode log show <编号>[/dim]")
console.print()
@log_app.command("show")
def log_show(
log_id: int = typer.Argument(..., help="日志编号"),
):
"""查看某次任务的详细执行过程。"""
from kaiwu.audit.logger import show_log
data = show_log(log_id)
if not data:
console.print(f" [red]未找到日志 #{log_id}[/red]")
return
icon = "✅ 成功" if data.get("success") else "❌ 失败"
console.print()
console.print(f" [bold]任务 #{log_id} 详情[/bold]")
console.print(" " + "" * 45)
console.print(f" 任务:{data.get('task', '')[:80]}")
console.print(f" 时间:{data.get('timestamp', '')[:19]}")
console.print(f" 模型:{data.get('model', '')}")
console.print(f" 耗时:{data.get('elapsed_s', 0):.1f}")
console.print(f" 结果:{icon}")
console.print()
# 执行过程
events = data.get("events", [])
if events:
console.print(" [bold cyan]执行过程[/bold cyan]")
for ev in events:
stage = ev.get("stage", "")
detail = ev.get("detail", "")[:80]
t = ev.get("time", "")
console.print(f" {t} [{stage:<15}] {detail}")
console.print()
# 修改文件
files = data.get("files_modified", [])
if files:
console.print(f" 修改文件:{', '.join(files)}")
console.print(f" 改动规模:+{data.get('lines_added', 0)}行 -{data.get('lines_removed', 0)}")
tp = data.get("tests_passed", 0)
tt = data.get("tests_total", 0)
if tt > 0:
console.print(f" 测试结果:{tp}/{tt}")
console.print(f" 重试次数:{data.get('retry_count', 0)}")
console.print()
@log_app.command("clear")
def log_clear():
"""清除所有任务日志。"""
confirm = Confirm.ask(" 确认清除所有任务日志?", default=False)
if not confirm:
console.print(" [dim]已取消[/dim]")
return
from kaiwu.audit.logger import clear_logs
count = clear_logs()
console.print(f" [green]已清除 {count} 条日志[/green]")

View File

@@ -0,0 +1,118 @@
"""
kwcode model — 模型查看/切换/探测命令。
"""
import typer
from kaiwu.cli.formatters import console
model_app = typer.Typer(name="model", help="模型管理")
@model_app.callback(invoke_without_command=True)
def model_show(ctx: typer.Context):
"""查看当前模型配置。"""
if ctx.invoked_subcommand is not None:
return
from kaiwu.cli.onboarding import load_config
config = load_config()
cfg = config.get("default", {})
model = cfg.get("model", "未配置")
base_url = cfg.get("base_url", "http://localhost:11434")
has_key = bool(cfg.get("api_key"))
console.print()
console.print(f" [bold]当前模型配置[/bold]")
console.print(" " + "" * 40)
console.print(f" 模型:{model}")
console.print(f" API {base_url}")
console.print(f" Key {'已配置' if has_key else '(无)'}")
# Try detect tier
try:
from kaiwu.core.model_capability import detect_model_tier
tier = detect_model_tier(model, base_url)
console.print(f" 能力:{tier.value}")
except Exception:
pass
console.print()
console.print(" [dim]切换模型kwcode model set <模型名>[/dim]")
console.print(" [dim]探测能力kwcode model probe[/dim]")
console.print()
@model_app.command("set")
def model_set(
name: str = typer.Argument(..., help="模型名称"),
):
"""切换模型(写入 config.yaml"""
from kaiwu.cli.onboarding import load_config, _save_config
config = load_config()
config.setdefault("default", {})
config["default"]["model"] = name
_save_config(config)
console.print(f" [green]✓ 模型已切换为 {name}[/green]")
console.print(" [dim]重新启动 kwcode 生效[/dim]")
@model_app.command("probe")
def model_probe():
"""探测当前模型能力(参数量/上下文/推理能力)。"""
from kaiwu.cli.onboarding import load_config
config = load_config()
cfg = config.get("default", {})
model = cfg.get("model", "未配置")
base_url = cfg.get("base_url", "http://localhost:11434")
console.print(f" 探测模型 {model}...")
# Try Ollama API
try:
import httpx
resp = httpx.post(
f"{base_url}/api/show",
json={"name": model},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
params = data.get("details", {}).get("parameter_size", "未知")
family = data.get("details", {}).get("family", "未知")
quant = data.get("details", {}).get("quantization_level", "未知")
console.print()
console.print(f" [bold]模型信息[/bold]")
console.print(" " + "" * 40)
console.print(f" 名称:{model}")
console.print(f" 家族:{family}")
console.print(f" 参数:{params}")
console.print(f" 量化:{quant}")
# Detect reasoning
try:
from kaiwu.llm.llama_backend import LLMBackend
is_reasoning = LLMBackend._check_reasoning_model(model)
console.print(f" 推理:{'✅ reasoning模型' if is_reasoning else '标准模型'}")
except Exception:
pass
# Detect tier
try:
from kaiwu.core.model_capability import detect_model_tier
tier = detect_model_tier(model, base_url)
console.print(f" 能力:{tier.value}")
except Exception:
pass
console.print()
return
except Exception:
pass
console.print(f" [yellow]无法通过 Ollama API 探测 {model}[/yellow]")
console.print(f" [dim]请确认 {base_url} 可访问且模型已下载[/dim]")
console.print()

View File

@@ -55,6 +55,8 @@ from kaiwu.cli.commands.task import (
build_pipeline,
run_task,
)
from kaiwu.cli.commands.log_cmd import log_app
from kaiwu.cli.commands.model_cmd import model_app
from kaiwu.cli.repl import repl
app = typer.Typer(
@@ -68,6 +70,8 @@ app.add_typer(expert_app)
app.add_typer(checkpoint_app)
app.add_typer(telemetry_app)
app.add_typer(skill_app)
app.add_typer(log_app)
app.add_typer(model_app)
app.command("init")(cmd_init)
app.command("memory")(cmd_memory)
app.command("status")(cmd_status)

View File

@@ -40,6 +40,7 @@ from kaiwu.notification.flywheel_notifier import FlywheelNotifier
from kaiwu.flywheel.strategy_stats import StrategyStats
from kaiwu.flywheel.user_pattern_memory import UserPatternMemory
from kaiwu.telemetry.client import TelemetryClient
from kaiwu.audit.logger import AuditLogger
logger = logging.getLogger(__name__)
@@ -133,6 +134,7 @@ class PipelineOrchestrator:
self._strategy_stats = StrategyStats()
self._user_patterns = UserPatternMemory()
self._telemetry = TelemetryClient()
self._audit = AuditLogger()
self.bus = bus or EventBus()
self._wink = WinkMonitor()
self._cognitive_gate = CognitiveGate()
@@ -156,6 +158,7 @@ class PipelineOrchestrator:
Returns {"success": bool, "context": TaskContext, "error": str|None, "elapsed": float}.
"""
start_time = time.time()
self._audit.start()
# 任务级超时看门狗
TASK_TIMEOUT_S = 300 # 单任务最长5分钟
@@ -573,6 +576,8 @@ class PipelineOrchestrator:
self._persist_reflection(project_root, ctx, gate_result, success=True)
# 飞轮:策略统计 + 用户模式 + 遥测
self._record_flywheel(ctx, gate_result, True)
# 审计日志
self._audit.write(ctx, elapsed, True, getattr(self, '_model_name', 'unknown'))
return {
"success": True,
"context": ctx,
@@ -611,6 +616,8 @@ class PipelineOrchestrator:
self._persist_reflection(project_root, ctx, gate_result, success=False)
# 飞轮:策略统计 + 用户模式 + 遥测
self._record_flywheel(ctx, gate_result, False)
# 审计日志
self._audit.write(ctx, elapsed, False, getattr(self, '_model_name', 'unknown'))
return {
"success": False,
"context": ctx,
@@ -823,12 +830,12 @@ class PipelineOrchestrator:
logger.debug("Reviewer failed (non-blocking): %s", e)
return {"aligned": True, "confidence": 0.0, "gap": ""}
@staticmethod
def _emit(callback, stage: str, detail: str):
"""Emit status update if callback provided."""
def _emit(self, callback, stage: str, detail: str):
"""Emit status update if callback provided. Also logs to audit."""
if callback:
callback(stage, detail)
logger.info("[%s] %s", stage, detail)
self._audit.log(stage, detail)
def _get_max_retries(self, gate_result: dict) -> int:
"""Dynamic retry budget based on task difficulty."""

View File

@@ -277,6 +277,9 @@ class GeneratorExpert:
if not modified:
continue
# 对齐缩进LLM返回的modified可能丢失class内方法的缩进
modified = self._align_indentation(original, modified)
# Verify original exists in file (should always be true since we read it)
if original not in content:
logger.error("Extracted original not found in file — this should not happen")
@@ -573,6 +576,46 @@ class GeneratorExpert:
ctx.generator_output = result
return result
@staticmethod
def _align_indentation(original: str, modified: str) -> str:
"""
让modified的基础缩进和original保持一致。
解决LLM生成class方法时丢失缩进的系统性bug
original有4空格缩进class内方法但LLM返回0空格顶层函数
apply_patch替换后方法"跑出"class导致IndentationError。
"""
orig_lines = original.split("\n")
mod_lines = modified.split("\n")
if not orig_lines or not mod_lines:
return modified
# 获取original第一个非空行的缩进
orig_indent = 0
for line in orig_lines:
if line.strip():
orig_indent = len(line) - len(line.lstrip())
break
# 获取modified第一个非空行的缩进
mod_indent = 0
for line in mod_lines:
if line.strip():
mod_indent = len(line) - len(line.lstrip())
break
diff = orig_indent - mod_indent
if diff <= 0:
return modified # modified缩进已经>=original不需要调整
pad = " " * diff
aligned = []
for line in mod_lines:
if line.strip(): # 非空行加缩进
aligned.append(pad + line)
else:
aligned.append(line)
return "\n".join(aligned)
@staticmethod
def _extract_function(content: str, func_name: str) -> Optional[str]:
"""Extract a complete function/method from file content by name."""

165
tests/test_audit_model.py Normal file
View File

@@ -0,0 +1,165 @@
"""
Tests for audit logger, model commands, and _align_indentation fix.
"""
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
class TestAuditLogger(unittest.TestCase):
"""Test AuditLogger write/list/show/clear."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.logs_dir = Path(self.tmpdir) / "logs"
def tearDown(self):
shutil.rmtree(self.tmpdir)
@patch("kaiwu.audit.logger.LOGS_DIR")
def test_write_creates_log(self, mock_dir):
mock_dir.__class__ = type(self.logs_dir)
from kaiwu.audit.logger import AuditLogger
with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir):
logger = AuditLogger()
logger.start()
logger.log("gate", "locator_repair | 难度easy")
logger.log("locator", "读取 test.py")
# Mock context
ctx = MagicMock()
ctx.user_input = "修复login函数"
ctx.gate_result = {"expert_type": "locator_repair", "difficulty": "easy"}
ctx.generator_output = {"patches": [{"file": "test.py", "original": "old", "modified": "new"}]}
ctx.verifier_output = {"tests_passed": 3, "tests_total": 3}
ctx.retry_count = 0
ctx.search_triggered = False
logger.write(ctx, 5.2, True, "qwen3:8b")
# Verify log file created
logs = list(self.logs_dir.glob("*.json"))
assert len(logs) == 1
data = json.loads(logs[0].read_text(encoding="utf-8"))
assert data["task"] == "修复login函数"
assert data["success"] is True
assert data["model"] == "qwen3:8b"
assert len(data["events"]) == 2
assert data["files_modified"] == ["test.py"]
@patch("kaiwu.audit.logger.LOGS_DIR")
def test_list_logs(self, mock_dir):
from kaiwu.audit.logger import list_logs
with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir):
self.logs_dir.mkdir(parents=True, exist_ok=True)
# Write 3 log files with distinct names
for i in range(3):
record = {"task": f"task {i}", "success": True, "elapsed_s": 1.0,
"timestamp": f"2026-05-06T10:00:0{i}", "model": "test"}
(self.logs_dir / f"2026-05-06_10000{i}_codegen.json").write_text(
json.dumps(record), encoding="utf-8"
)
logs = list_logs(limit=10)
assert len(logs) == 3
@patch("kaiwu.audit.logger.LOGS_DIR")
def test_clear_logs(self, mock_dir):
from kaiwu.audit.logger import AuditLogger, clear_logs
with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir):
self.logs_dir.mkdir(parents=True, exist_ok=True)
(self.logs_dir / "test.json").write_text("{}", encoding="utf-8")
count = clear_logs()
assert count == 1
assert len(list(self.logs_dir.glob("*.json"))) == 0
@patch("kaiwu.audit.logger.LOGS_DIR")
def test_max_logs_cleanup(self, mock_dir):
from kaiwu.audit.logger import AuditLogger, MAX_LOGS
with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir):
self.logs_dir.mkdir(parents=True, exist_ok=True)
# Create MAX_LOGS + 5 files
for i in range(MAX_LOGS + 5):
(self.logs_dir / f"2026-01-01_{i:06d}_test.json").write_text("{}", encoding="utf-8")
al = AuditLogger()
al._cleanup()
remaining = list(self.logs_dir.glob("*.json"))
assert len(remaining) == MAX_LOGS
class TestAlignIndentation(unittest.TestCase):
"""Test Generator._align_indentation — the class method indentation bug fix."""
def test_class_method_indent_fix(self):
from kaiwu.experts.generator import GeneratorExpert
# Original is class method (4-space indent)
original = " def login(self, user):\n return True"
# LLM returns without class indent
modified = "def login(self, user):\n if not user:\n return False\n return True"
result = GeneratorExpert._align_indentation(original, modified)
# Should add 4 spaces to all non-empty lines
lines = result.split("\n")
assert lines[0] == " def login(self, user):"
assert lines[1] == " if not user:"
assert lines[2] == " return False"
assert lines[3] == " return True"
def test_no_change_needed(self):
from kaiwu.experts.generator import GeneratorExpert
original = "def foo():\n return 1"
modified = "def foo():\n return 2"
result = GeneratorExpert._align_indentation(original, modified)
assert result == modified
def test_already_more_indented(self):
from kaiwu.experts.generator import GeneratorExpert
original = "def foo():\n return 1"
modified = " def foo():\n return 2"
result = GeneratorExpert._align_indentation(original, modified)
# Should not change — modified already more indented
assert result == modified
def test_empty_lines_preserved(self):
from kaiwu.experts.generator import GeneratorExpert
original = " def foo():\n\n return 1"
modified = "def foo():\n\n return 2"
result = GeneratorExpert._align_indentation(original, modified)
lines = result.split("\n")
assert lines[0] == " def foo():"
assert lines[1] == "" # Empty line stays empty
assert lines[2] == " return 2"
def test_8_space_indent(self):
from kaiwu.experts.generator import GeneratorExpert
# Nested class method (8-space indent)
original = " def inner(self):\n pass"
modified = "def inner(self):\n return 42"
result = GeneratorExpert._align_indentation(original, modified)
assert result.startswith(" def inner(self):")
assert " return 42" in result
class TestHashlinePrompt(unittest.TestCase):
"""Verify HASHLINE_PROMPT exists and has required format markers."""
def test_prompt_exists(self):
from kaiwu.experts.generator import HASHLINE_PROMPT
assert "{task_description}" in HASHLINE_PROMPT
assert "{anchored_code}" in HASHLINE_PROMPT
assert "EDIT" in HASHLINE_PROMPT
assert "DELETE" in HASHLINE_PROMPT
assert "INSERT_AFTER" in HASHLINE_PROMPT
if __name__ == "__main__":
unittest.main()