#![allow(dead_code)]
use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::Connection;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::adapter::{block_begin_marker, block_end_marker, limits::SKILL_BODY_MAX_LINES};
use crate::db::harness as db_harness;
use crate::pattern::RedoCluster;
pub const SKILL_MARKER: &str = "asurada-generated";
pub const WORKFLOW_BLOCK_ID: &str = "workflow";
#[derive(Debug, Clone)]
pub struct HarnessProposal {
pub slug: String,
pub title: String,
pub description: String,
pub reason: String,
pub agent_path: PathBuf,
pub agent_body: String,
pub skill_path: PathBuf,
pub skill_body: String,
pub origin_ref_path: PathBuf,
pub origin_ref_body: String,
pub trigger_signature: i64,
pub source_count: usize,
pub project_name: String,
pub source_signal_ids: Vec<String>,
pub action_summary: Vec<(String, usize)>,
}
impl HarnessProposal {
pub fn all_paths(&self) -> Vec<PathBuf> {
vec![
self.agent_path.clone(),
self.skill_path.clone(),
self.origin_ref_path.clone(),
]
}
}
pub fn propose_full_harness(
conn: &Connection,
user_id: &str,
cluster: &RedoCluster,
project_root: &Path,
) -> Result<HarnessProposal> {
let preview = cluster.prompt_preview.as_deref().unwrap_or("(?)");
let title: String = {
let s: String = preview.chars().take(40).collect();
if preview.chars().count() > 40 {
format!("{}…", s)
} else {
s
}
};
let slug = format!("pattern-{:016x}", cluster.signature as u64);
let action_summary = extract_action_summary(conn, user_id, cluster.signature)?;
let source_signal_ids = collect_source_signal_ids(conn, user_id, cluster.signature)?;
let description = render_description(cluster.count, &title);
let reason = render_reason(cluster, &title, &action_summary);
let agent_path = project_root.join(format!(".claude/agents/{}.md", slug));
let agent_body = render_agent(&slug, &title, cluster);
let skill_path = project_root.join(format!(".claude/skills/{}/SKILL.md", slug));
let skill_body = render_skill(&slug, &title, &description, cluster, &action_summary);
let origin_ref_path =
project_root.join(format!(".claude/skills/{}/references/origin.md", slug));
let origin_ref_body = render_origin(cluster, &action_summary, &source_signal_ids);
Ok(HarnessProposal {
slug,
title,
description,
reason,
agent_path,
agent_body,
skill_path,
skill_body,
origin_ref_path,
origin_ref_body,
trigger_signature: cluster.signature,
source_count: cluster.count,
project_name: cluster.project.clone(),
source_signal_ids,
action_summary,
})
}
pub fn apply_full_harness(
conn: &Connection,
user_id: &str,
project_root: &Path,
proposal: &HarnessProposal,
force: bool,
) -> Result<db_harness::Harness> {
let targets: &[(&Path, &str)] = &[
(&proposal.agent_path, &proposal.agent_body),
(&proposal.skill_path, &proposal.skill_body),
(&proposal.origin_ref_path, &proposal.origin_ref_body),
];
if !force {
for (p, _) in targets {
if p.exists() {
anyhow::bail!(
"이미 존재: {}\n 덮어쓰려면 --force",
p.display()
);
}
}
}
for (p, body) in targets {
write_atomic(p, body)?;
}
let relative_paths: Vec<String> = proposal
.all_paths()
.iter()
.map(|p| {
p.strip_prefix(project_root)
.map(|r| r.display().to_string())
.unwrap_or_else(|_| p.display().to_string())
})
.collect();
let h = db_harness::insert(
conn,
db_harness::HarnessInput {
user_id: user_id.into(),
project: proposal.project_name.clone(),
slug: proposal.slug.clone(),
title: proposal.title.clone(),
description: proposal.description.clone(),
reason: proposal.reason.clone(),
file_paths: relative_paths,
source_signal_ids: proposal.source_signal_ids.clone(),
source_cluster_signature: Some(proposal.trigger_signature),
metadata: serde_json::json!({
"action_summary": proposal.action_summary,
}),
},
)?;
Ok(h)
}
pub fn detect_skill_read(
conn: &Connection,
user_id: &str,
_project: &str,
tool_name: Option<&str>,
file_path: Option<&str>,
) -> Result<Option<String>> {
if tool_name != Some("Read") {
return Ok(None);
}
let Some(fp) = file_path else { return Ok(None); };
let needle = ".claude/skills/";
let Some(rest) = fp.find(needle).map(|i| &fp[i + needle.len()..]) else {
return Ok(None);
};
let Some(slash) = rest.find('/') else {
return Ok(None);
};
let slug = &rest[..slash];
let all = db_harness::list(conn, user_id, None)?;
Ok(all.into_iter().find(|h| h.slug == slug).map(|h| h.id))
}
pub fn append_caution_to_workflow(
skill_path: &Path,
caution_notes: &[String],
) -> Result<()> {
let body = std::fs::read_to_string(skill_path)
.with_context(|| format!("read {}", skill_path.display()))?;
let begin = block_begin_marker(WORKFLOW_BLOCK_ID);
let end = block_end_marker(WORKFLOW_BLOCK_ID);
let (Some(b), Some(e)) = (body.find(&begin), body.find(&end)) else {
anyhow::bail!(
"SKILL.md 에 ASURADA:{} 마커 없음 — 사용자가 제거했을 수 있음. \
자동 갱신 불가, 수동 검토 필요.",
WORKFLOW_BLOCK_ID
);
};
let inside = &body[b + begin.len()..e];
let mut updated_inside = inside.trim_end().to_string();
updated_inside.push_str("\n\n## 주의 (사용자 교정 누적)\n\n");
updated_inside.push_str("이 하네스 사용 중 사용자 교정이 누적되어 워크플로우에 ");
updated_inside.push_str("주의사항이 추가되었습니다:\n\n");
for note in caution_notes.iter().take(5) {
let trimmed: String = note.chars().take(120).collect();
let suffix = if note.chars().count() > 120 { "…" } else { "" };
updated_inside.push_str(&format!("- {}{}\n", trimmed, suffix));
}
updated_inside.push('\n');
let new_body = format!(
"{}\n{}\n{}",
&body[..b + begin.len()],
updated_inside.trim_end(),
&body[e..]
);
let tmp = skill_path.with_extension(format!(
"{}.asurada.tmp",
skill_path.extension().and_then(|e| e.to_str()).unwrap_or("md")
));
std::fs::write(&tmp, new_body)?;
std::fs::rename(&tmp, skill_path)?;
Ok(())
}
fn extract_action_summary(
conn: &Connection,
user_id: &str,
signature: i64,
) -> Result<Vec<(String, usize)>> {
use rusqlite::params;
let mut stmt = conn.prepare(
r#"SELECT json_extract(payload, '$.session_id') FROM events
WHERE user_id = ?1 AND event_type = 'hook.user_prompt'
AND CAST(json_extract(payload, '$.signature') AS INTEGER) = ?2"#,
)?;
let session_ids: Vec<String> = stmt
.query_map(params![user_id, signature], |r| {
let v: Option<String> = r.get(0)?;
Ok(v)
})?
.filter_map(|r| r.ok())
.flatten()
.collect();
if session_ids.is_empty() {
return Ok(vec![]);
}
let mut counts: HashMap<String, usize> = HashMap::new();
for sid in &session_ids {
let mut stmt2 = conn.prepare(
r#"SELECT json_extract(payload, '$.tool_name') FROM events
WHERE user_id = ?1 AND event_type = 'hook.tool_pre'
AND json_extract(payload, '$.session_id') = ?2
AND json_extract(payload, '$.tool_name') IS NOT NULL"#,
)?;
let tools: Vec<String> = stmt2
.query_map(params![user_id, sid], |r| {
let v: Option<String> = r.get(0)?;
Ok(v)
})?
.filter_map(|r| r.ok())
.flatten()
.collect();
for t in tools {
*counts.entry(t).or_insert(0) += 1;
}
}
let mut sorted: Vec<(String, usize)> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
sorted.truncate(10);
Ok(sorted)
}
fn collect_source_signal_ids(
conn: &Connection,
user_id: &str,
signature: i64,
) -> Result<Vec<String>> {
use rusqlite::params;
let mut stmt = conn.prepare(
r#"SELECT id FROM events
WHERE user_id = ?1
AND event_type IN ('hook.user_prompt', 'signal.redo')
AND CAST(json_extract(payload, '$.signature') AS INTEGER) = ?2
ORDER BY created_at ASC LIMIT 50"#,
)?;
let ids: Vec<String> = stmt
.query_map(params![user_id, signature], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
Ok(ids)
}
fn render_description(count: usize, title: &str) -> String {
format!(
"사용자가 {}회 반복한 작업 패턴: \"{}\". 비슷한 요청 시 적극적으로 사용해 \
이전 처리 맥락을 회상하고 일관성을 유지하라.",
count, title
)
}
fn render_reason(
cluster: &RedoCluster,
title: &str,
action_summary: &[(String, usize)],
) -> String {
let mut s = String::new();
s.push_str(&format!(
"## 왜 이 하네스가 만들어졌는가\n\n\
사용자가 \"**{}**\" 패턴을 **{}회** 반복했다고 Asurada 가 인지함. \
(signature: `{:016x}`)\n\n\
첫 발생: {}\n\
최근 발생: {}\n\n",
title, cluster.count, cluster.signature as u64, cluster.first_seen, cluster.last_seen
));
if !action_summary.is_empty() {
s.push_str("## 과거 처리 패턴 — 자주 사용된 도구\n\n");
for (tool, n) in action_summary.iter().take(5) {
s.push_str(&format!("- `{}`: {}회\n", tool, n));
}
s.push('\n');
}
s.push_str(
"## 진화 정책\n\n\
- 사용자가 SKILL.md 를 직접 편집하면 그 내용은 보존된다 \
(managed block 외 영역).\n\
- Asurada 는 사용 신호를 누적하다가 sub-pattern 이 보이면 \
`evolve` advice 를 발생시킨다.\n\
- 사용자 confirm 시 SKILL.md 의 `BEGIN ASURADA:workflow` 영역만 갱신.\n",
);
s
}
fn render_agent(slug: &str, title: &str, cluster: &RedoCluster) -> String {
format!(
"---\n\
name: {slug}-agent\n\
description: \"{title}\" 패턴 처리에 특화된 보조 에이전트. \
이 패턴이 들어오면 기존 누적 맥락을 끌어와 일관되게 처리한다.\n\
{marker}: true\n\
---\n\
\n\
# {title} 처리 에이전트\n\
\n\
## 역할\n\
\n\
사용자가 \"{title}\" 같은 작업을 요청할 때, 과거 {count}회 처리 맥락을 회상해\n\
일관된 결과를 만든다. *왜 이 에이전트가 존재하는지* 는 \
`references/origin.md` 참조.\n\
\n\
## 작업 원칙\n\
\n\
1. 같은 prompt 라도 *이번 요청의 차별점* 을 명시적으로 식별한다 (대상/scope/제약).\n\
2. 차별점이 없으면, 가장 최근의 성공적 처리 방식을 따른다.\n\
3. 차별점이 있으면, 변형이 필요한 부분을 사용자에게 짧게 확인한다.\n\
4. 작업 후 결과를 한 줄로 요약하고, 사용자 피드백을 다음 진화의 입력으로 남긴다.\n\
\n\
## 협업\n\
\n\
이 에이전트는 같은 슬러그의 skill (`{slug}`) 에 정의된 워크플로우를 따른다.\n\
skill 의 BEGIN ASURADA:workflow 블록이 진화에 따라 갱신될 수 있다.\n",
slug = slug,
title = title,
marker = SKILL_MARKER,
count = cluster.count,
)
}
fn render_skill(
slug: &str,
title: &str,
description: &str,
cluster: &RedoCluster,
action_summary: &[(String, usize)],
) -> String {
let begin = block_begin_marker(WORKFLOW_BLOCK_ID);
let end = block_end_marker(WORKFLOW_BLOCK_ID);
let mut workflow = String::from("\n## 워크플로우 (Asurada managed)\n\n");
workflow.push_str("**이 영역은 Asurada 가 진화 advice 를 통해 갱신한다. \
managed block 외 사용자 편집은 보존됨.**\n\n");
workflow.push_str("1. **의도 확인** — 사용자 요청을 한 문장으로 재진술.\n");
workflow.push_str("2. **차별점 탐지** — 과거와 다른 부분(대상/scope/제약)을 식별.\n");
if !action_summary.is_empty() {
workflow.push_str("3. **누적 도구 사용 패턴** — 과거 같은 패턴에서 자주 사용된 도구:\n");
for (tool, n) in action_summary.iter().take(5) {
workflow.push_str(&format!(" - `{}` ({}회)\n", tool, n));
}
workflow.push_str("4. **실행** — 위 도구를 우선 후보로 삼되, 차별점에 맞춰 조정.\n");
} else {
workflow.push_str("3. **실행** — 과거 도구 사용 패턴 누적 부족. \
일반 원칙으로 처리.\n");
}
workflow.push_str("5. **요약 보고 + 피드백 수집**.\n");
let body = format!(
"---\n\
name: {slug}\n\
description: {description}\n\
{marker}: true\n\
---\n\
\n\
# {title}\n\
\n\
사용자가 **{count}회** 반복한 작업을 안정화하는 skill. 같은 슬러그의 \
agent (`{slug}-agent`) 와 페어로 동작.\n\
\n\
## 트리거\n\
\n\
- 사용자 prompt 의 task signature 가 `{sig:016x}` 와 매칭\n\
- 또는 의미적으로 유사한 의도가 식별될 때\n\
\n\
{begin}\n{workflow}\n{end}\n\
\n\
## 자세한 배경\n\
\n\
이 skill 이 *왜* 만들어졌는지, 어떤 신호 누적에서 도출되었는지는 \
`references/origin.md` 를 참조하라. 이 파일은 lean 하게 유지된다 \
(Asurada 의 컨텍스트 예산 보호).\n\
\n\
---\n\
\n\
*Generated by Asurada — signature `{sig:016x}`. 진화 추적: \
`asurada harness get <id>`*\n",
slug = slug,
description = description,
marker = SKILL_MARKER,
title = title,
count = cluster.count,
sig = cluster.signature as u64,
begin = begin,
workflow = workflow,
end = end,
);
if body.lines().count() > SKILL_BODY_MAX_LINES {
tracing::warn!(
"[harness] SKILL.md 본문이 {}줄 한도 초과 — references/ 분리 권장",
SKILL_BODY_MAX_LINES
);
}
body
}
fn render_origin(
cluster: &RedoCluster,
action_summary: &[(String, usize)],
source_signal_ids: &[String],
) -> String {
let mut s = String::new();
s.push_str(&format!(
"# 왜 이 하네스가 만들어졌는가\n\n\
**생성 시각**: {}\n\
**트리거 signature**: `{:016x}`\n\
**누적 횟수**: {}\n\
**첫 발생**: {}\n\
**최근 발생**: {}\n\n",
Utc::now().to_rfc3339(),
cluster.signature as u64,
cluster.count,
cluster.first_seen,
cluster.last_seen,
));
s.push_str(&format!(
"## 원본 사용자 요청\n\n> {}\n\n",
cluster.prompt_preview.as_deref().unwrap_or("(no preview)")
));
if !action_summary.is_empty() {
s.push_str("## 과거 같은 패턴에서 사용된 도구\n\n| 도구 | 사용 횟수 |\n|------|-----------|\n");
for (tool, n) in action_summary {
s.push_str(&format!("| `{}` | {} |\n", tool, n));
}
s.push('\n');
}
s.push_str(&format!(
"## 원천 신호 (events 테이블)\n\n총 {}개:\n",
source_signal_ids.len()
));
for id in source_signal_ids.iter().take(20) {
s.push_str(&format!("- `{}`\n", id));
}
if source_signal_ids.len() > 20 {
s.push_str(&format!("- … 외 {}개\n", source_signal_ids.len() - 20));
}
s.push_str("\n## 진화 추적\n\n");
s.push_str("이 하네스의 사용 횟수와 진화 로그는 `asurada harness get <id>` 또는 ");
s.push_str("Devist Dashboard 의 하네스 페이지에서 확인.\n");
s
}
fn write_atomic(path: &Path, body: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!(
"{}.asurada.tmp",
path.extension().and_then(|e| e.to_str()).unwrap_or("md")
));
std::fs::write(&tmp, body).with_context(|| format!("write tmp {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename → {}", path.display()))?;
Ok(())
}
pub fn list_generated(project_root: &Path) -> Result<Vec<PathBuf>> {
let skills_dir = project_root.join(".claude/skills");
if !skills_dir.exists() {
return Ok(vec![]);
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let skill_md = entry.path().join("SKILL.md");
if !skill_md.exists() {
continue;
}
let body = std::fs::read_to_string(&skill_md).unwrap_or_default();
if body.contains(&format!("{}: true", SKILL_MARKER)) {
out.push(skill_md);
}
}
out.sort();
Ok(out)
}
pub fn resolve_project_path(
conn: &Connection,
user_id: &str,
project_name: &str,
) -> Result<PathBuf> {
let proj = crate::db::project::get(conn, user_id, project_name)?
.with_context(|| format!("프로젝트 미등록: {}. `devist project add` 로 등록 필요.", project_name))?;
Ok(PathBuf::from(proj.path))
}