1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//! Installing confer's Claude Code skills (/confer-watch, /confer-poll) and the tier-1 auto-resync
//! that keeps them current with the running binary.
//!
//! `cmd_install_skill` writes each skill in CONFER_SKILLS (from `templates`) with the machine's binary
//! path baked in, and (unless opted out) arms the SessionStart auto-heal hook. `resync_skills_if_stale`
//! is the SessionStart-time counterpart: if skills already exist but were baked from a different build,
//! silently re-derive them — never creating skills where none exist.
use crate::templates::CONFER_SKILLS;
use crate::{autoheal, config, hooks::write_session_hook, BUILD_SHA};
use anyhow::Result;
/// Tier-1 auto-heal: if confer skills are already installed in the default global dir but were baked
/// from a DIFFERENT build than this binary, silently re-derive them. This is safe to do without
/// asking — skills are a pure function of the on-disk binary (fixed templates + the binary's own
/// path), and SessionStart runs the NEW binary — so it closes the "updated the binary but forgot to
/// re-sync the skills" gap with zero agent action and nothing to judge. Returns the build it synced
/// to when it acted (for a one-line note), None when nothing was stale. It NEVER creates skills where
/// none exist — a fresh install is an explicit `install-skill` choice, not something to auto-heal —
/// and only touches the DEFAULT global dir (a `--dir` install is the agent's own placement to manage).
pub(crate) fn resync_skills_if_stale() -> Option<String> {
let dir = config::home().ok()?.join(".claude").join("skills");
let marker = dir.join("confer-watch").join(".confer-build");
// Present = installed here. Absent = not installed → not ours to create.
if !dir.join("confer-watch").join("SKILL.md").is_file() {
return None;
}
if std::fs::read_to_string(&marker).unwrap_or_default().trim() == BUILD_SHA {
return None; // already current — the common case, a cheap stat+read
}
let bin = std::env::current_exe().ok()?.to_string_lossy().to_string();
for (name, tmpl) in CONFER_SKILLS {
let filled = tmpl.replace("{CONFER}", &bin);
// Defensive: these templates are role-agnostic by design (design/32). If a future one ever
// baked {ROLE}/{HUB}, a role-blind resync would write a broken skill — so bail rather than
// corrupt a working install; the explicit `install-skill` (which knows role+hub) still fixes it.
if filled.contains("{ROLE}") || filled.contains("{HUB}") {
return None;
}
let d = dir.join(name);
std::fs::create_dir_all(&d).ok()?;
std::fs::write(d.join("SKILL.md"), filled).ok()?;
}
let _ = std::fs::write(&marker, BUILD_SHA);
Some(BUILD_SHA.to_string())
}
pub(crate) fn cmd_install_skill(
dir: Option<String>,
hub: Option<String>,
role: Option<String>,
no_autoheal: bool,
) -> Result<()> {
let bin = std::env::current_exe()?.to_string_lossy().to_string();
let hub_root = match hub {
Some(h) => std::fs::canonicalize(&h).unwrap_or_else(|_| std::path::PathBuf::from(h)),
None => config::repo_root()?,
};
let role = match role {
Some(r) => r,
None => config::resolve_role(None, &hub_root)?,
};
// Default to the GLOBAL skills dir (~/.claude/skills): a coordination watch
// skill is cross-project infrastructure, and Claude Code only auto-discovers
// skills from ~/.claude or the *current* project — so writing into the hub
// repo hides /watch from an agent whose session lives in its own code repo.
let dir = match dir {
Some(d) => std::path::PathBuf::from(d),
None => config::home()?.join(".claude").join("skills"),
};
let fill = |t: &str| {
t.replace("{CONFER}", &bin)
.replace("{HUB}", &hub_root.to_string_lossy())
.replace("{ROLE}", &role)
};
// ONE generic skill, shared by every agent on the machine — the skill text is role-agnostic
// (commands resolve the caller's role from the hub clone they're run in), so co-resident agents
// no longer clobber each other by baking their own role into a shared `confer-watch/SKILL.md`
// (design/32). Only {CONFER} (the machine's binary path, shared by co-resident agents) is baked.
for (name, tmpl) in CONFER_SKILLS {
let d = dir.join(name);
std::fs::create_dir_all(&d)?;
std::fs::write(d.join("SKILL.md"), fill(tmpl))?;
}
// Stamp the build these skills were baked from so the SessionStart tier-1 auto-heal can tell,
// cheaply, when a later binary update has left them stale and silently re-derive them.
let _ = std::fs::write(dir.join("confer-watch").join(".confer-build"), BUILD_SHA);
println!(
"wrote {}/{{confer-watch,confer-poll}}/SKILL.md",
dir.display()
);
// Migrate: remove OUR pre-namespacing skill dirs so an agent doesn't keep both /watch and
// /confer-watch. Only remove ones clearly OURS (mention confer) — never an unrelated skill.
for legacy in ["watch", "check-blackboard"] {
let sk = dir.join(legacy).join("SKILL.md");
if std::fs::read_to_string(&sk)
.map(|s| s.contains("confer"))
.unwrap_or(false)
{
let _ = std::fs::remove_dir_all(dir.join(legacy));
println!(" migrated: removed legacy /{legacy}");
}
}
println!(" confer: {bin}");
println!(" hub: {}", hub_root.display());
println!(" role: {role}");
// Full reactive stack: also install + enable the SessionStart auto-heal hook
// so a compacted session is told to re-arm a stale watcher. Inert
// until a watch registers a target; opt out with --no-autoheal.
if !no_autoheal {
let settings = config::home()?.join(".claude").join("settings.json");
match write_session_hook(&settings, &format!("{bin} session-heal")) {
Ok(()) => {
let _ = autoheal::set_enabled(true);
println!(" auto-heal: installed SessionStart hook → {} and enabled (confer autoheal off to disable)", settings.display());
}
Err(e) => eprintln!(
" auto-heal: skipped (couldn't edit {}: {e})",
settings.display()
),
}
}
println!(
"use: /confer-watch (Monitor, reactive/dormant) or /loop 45s /confer-poll (poll fallback)."
);
Ok(())
}