thirdshift 0.17.2

Turn a GitHub issue into a ready-for-review pull request, unattended
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! The Factory skills, embedded at compile time (ADR-0001), written out once
//! per Command and linked into each worktree its sessions run in, where the
//! Harness finds project skills (ADR-0012). Home skills from
//! `~/.thirdshift/skills` are linked the same way.

use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};

use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use tempfile::TempDir;

use crate::git::Git;
use crate::harness::Adapter;

mod directory;
use directory::CheckedDirectory;

#[cfg(test)]
mod tests;

/// The Factory skills, as they are written out and the Prompts and skills
/// page shows them, each in its `thirdshift-<skill>` directory.
pub static SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/skills");

/// A skill resolved by name, with the instructions the session must read.
pub struct Skill {
    pub name: String,
    pub path: PathBuf,
}

enum Source {
    Factory,
    Repository(PathBuf),
    Home(PathBuf),
}

/// Resolve a skill name. An exact Factory directory name wins, then a
/// repository skill in `.agents/skills/<name>/SKILL.md` or
/// `.claude/skills/<name>/SKILL.md` that resolves inside the worktree, then
/// `~/.thirdshift/skills/<name>/SKILL.md` when that file resolves inside the
/// home skills directory. The Factory shorthand `thirdshift-<name>` is last.
/// A symlink cannot select instructions outside the directory being searched.
pub fn find(worktree: &Path, name: &str) -> Result<Skill> {
    let mut available = BTreeMap::new();
    for dir in SKILLS.dirs() {
        available.insert(dir.path().to_string_lossy().into_owned(), Source::Factory);
    }
    for (skill_name, path) in repository_skills(worktree)? {
        available
            .entry(skill_name)
            .or_insert(Source::Repository(path));
    }
    for (skill_name, path) in home_skills()? {
        available.entry(skill_name).or_insert(Source::Home(path));
    }
    let selected = available.get_key_value(name).or_else(|| {
        available
            .get_key_value(&format!("thirdshift-{name}"))
            .filter(|(_, source)| matches!(source, Source::Factory))
    });
    if let Some((name, source)) = selected {
        return Ok(Skill {
            path: match source {
                Source::Factory => written_out()?.join(name).join("SKILL.md"),
                Source::Repository(path) | Source::Home(path) => path.clone(),
            },
            name: name.clone(),
        });
    }
    bail!(
        "unknown skill {name:?}; available skills: {}",
        available.keys().cloned().collect::<Vec<_>>().join(", ")
    )
}

/// `candidate` resolves to a file or directory inside `root`. Symlinks are
/// followed, so a skill that points outside the directory being searched is
/// not a match.
fn resolved_inside(root: &Path, candidate: &Path) -> bool {
    fs::canonicalize(candidate).is_ok_and(|target| target.starts_with(root))
}

/// Repository skills whose `SKILL.md` resolves inside `worktree`. `.agents`
/// is listed before `.claude`, and the caller keeps the first name.
fn repository_skills(worktree: &Path) -> Result<Vec<(String, PathBuf)>> {
    let root = fs::canonicalize(worktree)
        .with_context(|| format!("could not resolve {}", worktree.display()))?;
    let mut skills = Vec::new();
    for folder in [".agents/skills", ".claude/skills"] {
        let folder_path = worktree.join(folder);
        match fs::symlink_metadata(&folder_path) {
            Ok(_) => {}
            Err(error) if error.kind() == ErrorKind::NotFound => continue,
            Err(error) => return Err(error).with_context(|| format!("could not list {folder}")),
        }
        if !resolved_inside(&root, &folder_path) {
            continue;
        }
        let entries =
            fs::read_dir(&folder_path).with_context(|| format!("could not list {folder}"))?;
        for entry in entries {
            let entry = entry?;
            let path = entry.path().join("SKILL.md");
            if !resolved_inside(&root, &path) || !path.is_file() {
                continue;
            }
            let name = entry.file_name().into_string().map_err(|_| {
                anyhow::anyhow!(
                    "skill directory name is not UTF-8: {}",
                    entry.path().display()
                )
            })?;
            skills.push((name, path));
        }
    }
    Ok(skills)
}

struct HomeSkill {
    name: String,
    skill_md: PathBuf,
}

struct HomeCatalog {
    root: PathBuf,
    skills: Vec<HomeSkill>,
}

/// Home skills, each `~/.thirdshift/skills/<name>/SKILL.md` that resolves
/// inside that directory. No home directory, or `HOME` unset, is none.
fn home_skills() -> Result<Vec<(String, PathBuf)>> {
    Ok(read_home_skills()?
        .map(|catalog| {
            catalog
                .skills
                .into_iter()
                .map(|skill| (skill.name, skill.skill_md))
                .collect()
        })
        .unwrap_or_default())
}

/// The canonical home skills directory and the skills inside it.
fn read_home_skills() -> Result<Option<HomeCatalog>> {
    let Ok(home) = crate::config::home() else {
        return Ok(None);
    };
    let skills_dir = home.join(".thirdshift/skills");
    let entries = match fs::read_dir(&skills_dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(error).with_context(|| format!("could not list {}", skills_dir.display()));
        }
    };
    let root = match fs::canonicalize(&skills_dir) {
        Ok(root) => root,
        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(error)
                .with_context(|| format!("could not resolve {}", skills_dir.display()));
        }
    };
    let mut skills = Vec::new();
    for entry in entries {
        let entry = entry.with_context(|| format!("could not list {}", skills_dir.display()))?;
        // One odd directory name must not make every skill lookup fail.
        let Ok(name) = entry.file_name().into_string() else {
            continue;
        };
        if !usable_home_skill_name(&name) {
            continue;
        }
        let path = entry.path().join("SKILL.md");
        if !resolved_inside(&root, &path) || !path.is_file() {
            continue;
        }
        skills.push(HomeSkill {
            name,
            skill_md: path,
        });
    }
    Ok(Some(HomeCatalog { root, skills }))
}

/// A single path component that can be one line of `.git/info/exclude`.
fn usable_home_skill_name(name: &str) -> bool {
    !name.is_empty()
        && name != "."
        && name != ".."
        && !name
            .chars()
            .any(|ch| ch.is_control() || matches!(ch, '/' | '\\'))
}

/// A gitignore line that matches only `path`. `path` starts with `/`.
fn exclude_literal(path: &str) -> String {
    let chars: Vec<char> = path.chars().collect();
    let last_non_space = chars.iter().rposition(|ch| *ch != ' ').unwrap_or(0);
    let mut escaped = String::new();
    for (index, ch) in chars.into_iter().enumerate() {
        if matches!(ch, '*' | '?' | '[' | ']' | '\\') || (ch == ' ' && index > last_non_space) {
            escaped.push('\\');
        }
        escaped.push(ch);
    }
    escaped
}

/// The temp directory the Command wrote the Factory skills out to, once it
/// has.
static WRITTEN: Mutex<Option<TempDir>> = Mutex::new(None);

/// Link every Factory skill into `worktree`'s project skills for `adapter`,
/// `.claude/skills/` or `.agents/skills/`, writing them out first if the
/// Command has not yet, and make sure the repository's `.git/info/exclude`
/// keeps them out of git. Home skills are copied into that same folder and
/// linked under their own names, except where the repository already has
/// that skill or the name is a Factory skill. The copy is what a Trial
/// reads: its sandbox binds this folder and not `~/.thirdshift/skills`. The
/// links are left in place: they go with the worktree.
pub fn link_into(worktree: &Path, adapter: &dyn Adapter) -> Result<()> {
    let dir = adapter.project_skills();
    // Refuse an unsafe path before writing skills or updating excludes.
    let project_skills = CheckedDirectory::check(worktree, Path::new(dir))?;
    let written = written_out()?;
    exclude(&Git::new(worktree), &format!("/{dir}/thirdshift-*"))?;
    let project_skills = project_skills.create_missing()?;
    for skill in SKILLS.dirs() {
        let name = skill.path();
        project_skills.link(&written.join(name), name)?;
    }
    link_home_skills(worktree, &project_skills, dir)?;
    Ok(())
}

/// Link each home skill under its own name, unless the repository already has
/// that skill, the name is a Factory skill, or the destination is some other
/// entry. The worktree link points at a copy in the Factory skills folder.
/// Each linked name is excluded on its own, because home skills do not share
/// the `thirdshift-` prefix. The entry stays, as the Factory pattern does.
fn link_home_skills(worktree: &Path, project_skills: &CheckedDirectory, dir: &str) -> Result<()> {
    let Some(catalog) = read_home_skills()? else {
        return Ok(());
    };
    if catalog.skills.is_empty() {
        return Ok(());
    }
    // Probe each name. Listing the project skills directory would fail when
    // it is searchable but not readable, which Factory linking allows.
    let root = fs::canonicalize(worktree)
        .with_context(|| format!("could not resolve {}", worktree.display()))?;
    for skill in catalog.skills {
        if factory_skill(&skill.name) || repository_has_skill(&root, worktree, &skill.name) {
            continue;
        }
        let Some(home_directory) = skill.skill_md.parent() else {
            continue;
        };
        let destination = worktree.join(dir).join(&skill.name);
        let published = written_out()?.join(&skill.name);
        if !home_link_allowed(&destination, &published, home_directory)? {
            continue;
        }
        publish_home_skill(&catalog.root, &skill.name, home_directory, &published)?;
        exclude(
            &Git::new(worktree),
            &exclude_literal(&format!("/{dir}/{}", skill.name)),
        )?;
        project_skills.link(&published, Path::new(&skill.name))?;
    }
    Ok(())
}

/// A Factory skill already uses this directory name in the written-out folder.
fn factory_skill(name: &str) -> bool {
    SKILLS.dirs().any(|dir| dir.path().to_str() == Some(name))
}

static HOME_SKILL_PUBLICATION: Mutex<()> = Mutex::new(());

/// Publish one immutable copy per Command in the Factory skills folder.
/// Parallel Sessions may link it again while another Session reads it.
/// A symlink is kept only by copying the file it names when that file stays
/// inside `root`, so the published tree does not point at the operator's home.
fn publish_home_skill(root: &Path, name: &str, source: &Path, destination: &Path) -> Result<()> {
    let _publication = HOME_SKILL_PUBLICATION
        .lock()
        .unwrap_or_else(PoisonError::into_inner);
    if destination.join("SKILL.md").is_file() {
        return Ok(());
    }
    let staged = tempfile::tempdir_in(
        destination
            .parent()
            .context("home skill destination has no parent")?,
    )
    .with_context(|| format!("could not stage home skill {name}"))?;
    copy_inside(source, staged.path(), root, 0)?;
    if !staged.path().join("SKILL.md").is_file() {
        bail!("could not publish home skill {name}");
    }
    fs::rename(staged.path(), destination)
        .with_context(|| format!("could not publish home skill {name}"))?;
    Ok(())
}

fn copy_inside(source: &Path, destination: &Path, root: &Path, depth: u32) -> Result<()> {
    if depth > 32 {
        bail!("home skill {} is nested too deeply", source.display());
    }
    fs::create_dir_all(destination)
        .with_context(|| format!("could not create {}", destination.display()))?;
    let entries =
        fs::read_dir(source).with_context(|| format!("could not list {}", source.display()))?;
    for entry in entries {
        let entry = entry.with_context(|| format!("could not list {}", source.display()))?;
        let path = entry.path();
        if !resolved_inside(root, &path) {
            continue;
        }
        let file_type = entry
            .file_type()
            .with_context(|| format!("could not inspect {}", path.display()))?;
        let target = destination.join(entry.file_name());
        if file_type.is_symlink() {
            let metadata = fs::metadata(&path)
                .with_context(|| format!("could not inspect {}", path.display()))?;
            if metadata.is_dir() {
                copy_inside(&path, &target, root, depth + 1)?;
            } else if metadata.is_file() {
                fs::copy(&path, &target)
                    .with_context(|| format!("could not copy {}", path.display()))?;
            }
        } else if file_type.is_dir() {
            copy_inside(&path, &target, root, depth + 1)?;
        } else if file_type.is_file() {
            fs::copy(&path, &target)
                .with_context(|| format!("could not copy {}", path.display()))?;
        }
    }
    Ok(())
}

/// `worktree` has a committed skill named `name` in either project skills
/// directory. Missing paths and directories that cannot be searched are not.
fn repository_has_skill(root: &Path, worktree: &Path, name: &str) -> bool {
    [".agents/skills", ".claude/skills"]
        .into_iter()
        .any(|folder| {
            let path = worktree.join(folder).join(name).join("SKILL.md");
            resolved_inside(root, &path) && path.is_file()
        })
}

/// The destination is absent, or it is already this home skill's link and can
/// be refreshed. A link still aimed at the home directory is refreshed onto
/// the published copy. A repository file or directory, or a link to somewhere
/// else, stays.
fn home_link_allowed(destination: &Path, published: &Path, home_directory: &Path) -> Result<bool> {
    match fs::symlink_metadata(destination) {
        Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
        Err(error) => {
            Err(error).with_context(|| format!("could not inspect {}", destination.display()))
        }
        Ok(metadata) if metadata.file_type().is_symlink() => {
            let target = fs::read_link(destination).ok();
            Ok(target.as_deref() == Some(published) || target.as_deref() == Some(home_directory))
        }
        Ok(_) => Ok(false),
    }
}

/// Remove the Factory skills the Command wrote out, if it did, once its
/// sessions are over.
pub fn remove_written() {
    drop(
        WRITTEN
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .take(),
    );
}

/// Where the Command wrote the Factory skills out, writing them now if it
/// has not yet.
pub(crate) fn written_out() -> Result<PathBuf> {
    let mut written = WRITTEN.lock().unwrap_or_else(PoisonError::into_inner);
    if let Some(dir) = &*written {
        return Ok(dir.path().to_owned());
    }
    let dir = tempfile::Builder::new()
        .prefix("thirdshift-skills-")
        .tempdir()?;
    SKILLS
        .extract(dir.path())
        .context("could not write the Factory skills")?;
    let path = dir.path().to_owned();
    *written = Some(dir);
    Ok(path)
}

/// Add the pattern `exclude` to the `.git/info/exclude` of the repository
/// `git` works in, unless it is there already. The file is shared by every
/// worktree, so the entry is left there, under a lock other Runs from the
/// same Launch directory take too.
pub(crate) fn exclude(git: &Git, exclude: &str) -> Result<()> {
    let _lock = git.lock("thirdshift-exclude.lock")?;
    let info = git.common_dir()?.join("info");
    let path = info.join("exclude");
    let existing = match fs::read_to_string(&path) {
        Ok(existing) => existing,
        Err(error) if error.kind() == ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(error).with_context(|| format!("could not read {}", path.display()));
        }
    };
    if existing.lines().any(|line| line == exclude) {
        return Ok(());
    }
    fs::create_dir_all(&info).with_context(|| format!("could not create {}", info.display()))?;
    let separator = if existing.is_empty() || existing.ends_with('\n') {
        ""
    } else {
        "\n"
    };
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .and_then(|mut file| writeln!(file, "{separator}{exclude}"))
        .with_context(|| format!("could not add an exclude pattern to {}", path.display()))
}