Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,54 @@ skills and failed replacements must stay outside discovery roots because
OpenCode also scans hidden subdirectories. Artifact backups stay beside the
artifact target. The installer prints each backup path and retains its contents.

### Install into a project

Some harnesses read project skills only from the working directory, which matters
when an app starts agents in per-thread git worktrees such as
[T3 Code](docs/t3code.md#project-install-for-t3-worktrees). `--project` copies
skills into an existing local repository instead of the user directories:

```bash
npx @3metajun/mstack --harness codex,claude,pi --project /path/to/repo --dry-run
npx @3metajun/mstack --harness codex,claude,pi --project /path/to/repo
```

| Harness | Project root under `<repo>` | Reads it from |
| --- | --- | --- |
| Codex | `.agents/skills/` | the working directory up to the repository root |
| OpenCode | `.agents/skills/` | the working directory up to the git worktree root (also reads `.opencode/skills/`) |
| pi | `.agents/skills/` | the working directory up to the repository root |
| Grok | `.agents/skills/` | every directory between the working directory and the repository root |
| Antigravity (`agy`) | `.agents/skills/` | the working directory (also reads `.gemini/skills/` and `.agent/skills/`) |
| Claude Code | `.claude/skills/` | the working directory only |

Codex, OpenCode, pi, Grok and Antigravity share one copy per skill in
`.agents/skills/`. Cursor reads that directory and `.claude/skills/` too, so it
needs no target of its own. Claude gets the same adapter output as at user level
(no frontmatter `name`). Project installs copy and never link, keep the
installer's staging, locking, rollback and `--replace` backup behavior, and
ignore `HARNESS_SKILLS_*_DIR` overrides.

Rules:

- The directory must exist. The installer refuses your home directory (use a
user-level install), anything inside the mstack package, any path inside
`.harness-skills-*` transaction storage, and any directory inside a skill
discovery root such as `.agents/skills` or `.claude/skills`, because backups
there would be scanned as skills. It checks physical paths, so symlinks,
junctions and letter case cannot redirect a target, backup or stage directory
outside the project or into the package or your user-level skill roots.
- Skills only. `--artifact` is refused with `--project`: agent roles, tools, the
guide and session context are user-owned configuration and should not land in a
repository implicitly. `--migrate` and `--environment` (including SSH) are
refused too.
- `--replace` backups go to `<repo>/.agents/.harness-skills-backups/` and
`<repo>/.claude/.harness-skills-backups/`, beside the skill roots and never under
them. Delete them when you no longer need them, and add `.harness-skills-*` to
`.gitignore`.
- Commit the installed skills deliberately, or gitignore them. A new git worktree
holds only committed files, so uncommitted project skills do not appear in it.

## Use with Claude Code and Claude Desktop

Claude Code and the Code tab of the Claude desktop app share one skill system,
Expand Down
28 changes: 26 additions & 2 deletions docs/harness-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ to the official documentation used for each entry.
| --- | --- | --- | --- |
| Codex | `~/.agents/skills/` | `.agents/skills/` | Agent Skills fields; Codex-specific `agents/openai.yaml` lives beside each skill |
| Claude Code | `~/.claude/skills/` | `.claude/skills/` | `name`, `description`, `license`, `compatibility`, `metadata`, and Claude invocation fields |
| OpenCode | `~/.agents/skills/`, `~/.config/opencode/skills/`, `~/.claude/skills/` | `.opencode/skills/` | `license`, `compatibility`, and `metadata` |
| pi | `~/.agents/skills/`, `~/.pi/agent/skills/` | `.pi/skills/` | `license`, `compatibility`, `metadata`, `allowed-tools`, and `disable-model-invocation` |
| OpenCode | `~/.agents/skills/`, `~/.config/opencode/skills/`, `~/.claude/skills/` | `.opencode/skills/`, `.claude/skills/`, `.agents/skills/` | `license`, `compatibility`, and `metadata` |
| pi | `~/.agents/skills/`, `~/.pi/agent/skills/` | `.pi/skills/`, `.agents/skills/` | `license`, `compatibility`, `metadata`, `allowed-tools`, and `disable-model-invocation` |
| Antigravity (`agy`) | `~/.gemini/config/skills/`, `~/.gemini/antigravity-cli/skills/` | `.gemini/skills/`, `.agents/skills/`, `.agent/skills/` | `name` (required) and `description`; other fields are not documented |
| Grok | `~/.grok/skills/`, `~/.agents/skills/`, `~/.claude/skills/`, `~/.cursor/skills/` | `.grok/skills/`, `.agents/skills/`, `.claude/skills/`, `.cursor/skills/` | `name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`, `user-invocable`, and `disable-model-invocation` |
| Cursor | `~/.cursor/skills/`, `~/.agents/skills/` | `.cursor/skills/`, `.agents/skills/`, `.claude/skills/` | Agent Skills fields |
Expand All @@ -31,6 +31,30 @@ Sources:
which listed skills from `~/.agents/skills/`.
- [Cursor skills](https://cursor.com/docs/context/skills)

### Project install targets

`--project <dir>` writes to one project root per harness, defined as `project` in
`profiles/harnesses.json`. Where a harness reads several project directories,
mstack picks the one that lets harnesses share a copy:

| Harness | mstack project target | Evidence |
| --- | --- | --- |
| Codex | `.agents/skills/` | Codex skills documentation |
| OpenCode | `.agents/skills/` | The OpenCode skills page lists `.opencode/skills/`, `.claude/skills/` and `.agents/skills/` as project paths and walks up to the git worktree root |
| pi | `.agents/skills/` | The pi skills page: project `.agents/skills/` directories are discovered from the working directory through its ancestors, stopping at the repository root. `.pi/skills/` is also read |
| Grok | `.agents/skills/` | Grok user guide: scans `.agents/skills/` at each tier and walks every directory between the working directory and the repository root |
| Antigravity | `.agents/skills/` | T3 Code `AntigravitySkills.ts` scans `<cwd>/.gemini/skills`, `<cwd>/.agents/skills` and `<cwd>/.agent/skills`. Its canonical frontmatter is unchanged, so it shares the copy |
| Claude Code | `.claude/skills/` | Claude reads only `<cwd>/.claude/skills` (T3 Code `ClaudeSkills.ts`); it needs its own copy without the frontmatter `name` |
| Cursor | none needed | T3 Code `CursorSkills.ts` scans `<cwd>/.cursor`, `.agents`, `.codex` and `.claude` skills, so the two targets above cover it |

Unlike the user level, Antigravity joins the shared copy because it does read
`.agents/skills/` in a project. The Cursor, Claude and Antigravity rows were
checked against T3 Code commit `8f75697`. The OpenCode, pi and Grok rows come from
their documentation and were not run in a thread. Project installs are skills
only: agent files have project locations for only some harnesses
(`.claude/agents`, `.opencode/agents`, `.codex/agents`), in different formats, so
`--artifact` is refused with `--project`.

The canonical tree keeps the Agent Skills fields that all supported Harnesses
can read. Claude Code accepts `metadata` but does not act on its contents, so
`adapters/claude.json` removes that map and surfaces the logger requirement in
Expand Down
17 changes: 17 additions & 0 deletions docs/t3code.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ By default T3 creates a worktree for a thread at:
- `LAST_CHAT` scans Claude Code, Codex, and pi transcripts only. Threads run through Cursor, Grok, OpenCode, or Antigravity, and T3's own thread history, are not read, so those worktrees can look idle. Set `MSTACK_TRANSCRIPTS_DIR` to scan one directory instead.
- Treat its `safe` bucket as a report. T3 has its own cleanup under **Settings → Storage** that removes only T3-managed worktrees without running sessions or uncommitted work. I did not check how T3 reacts when a worktree it manages is removed with `git worktree remove`, so use T3's cleanup or thread deletion for those.

## Project install for T3 worktrees

A user-level install is the simplest route. When a provider should find mstack only for one repository, or Claude and Antigravity need a copy in the thread's own directory, install into the project:

```bash
npx @3metajun/mstack --harness codex,claude,pi --project /path/to/repo --dry-run
npx @3metajun/mstack --harness codex,claude,pi --project /path/to/repo
```

This copies the skills into `<repo>/.agents/skills` (Codex, Pi, OpenCode, Grok, Antigravity) and `<repo>/.claude/skills` (Claude Code). Cursor reads both. The README has the [per-harness table](../README.md#install-into-a-project) and the rules: skills only, existing directory required, `--replace` backups under `.harness-skills-backups/` beside the skill roots.

- **Commit it or ignore it, on purpose.** T3 creates a worktree with `git worktree add` from a ref (`GitVcsDriverCore.ts` in T3 commit `8f75697`). A new worktree therefore holds only what is committed at that ref. Project skills you installed but did not commit exist in the main checkout and are missing from a fresh T3 thread. If the team should share them, commit them to the base branch before starting threads. If they are personal, add `.agents/skills/`, `.claude/skills/` and `.harness-skills-*` to `.gitignore` (or `.git/info/exclude`) and install again in each worktree, or keep using the user-level install.
- **An existing thread's worktree** has its own copy of the tree. Run the installer with `--project <worktree path>` to add skills there, then restart the agent session.
- **Precedence.** For Claude, a user copy beats a project copy of the same name. Cursor deduplicates by name with project roots first. Re-run the install after updating mstack, with `--replace`, to refresh a project copy.
- **Pi** still needs the project approved in Pi before it lists project skills.
- Not checked in a live T3 thread: that OpenCode, Pi, and Grok list `.agents/skills` from a worktree cwd. The paths come from their documentation.

## A `t3.json` setup script

T3 reads `t3.json` at the repository root. An invalid file is ignored as a whole, including its icon and scripts. For a project that uses mstack, a worktree starts from the committed tree, so skills and `.harness/` files are already there. The one gap is usually dependencies and gitignored files. This recipe installs dependencies before the agent starts and makes new threads default to worktrees:
Expand Down
6 changes: 6 additions & 0 deletions profiles/harnesses.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"codex": {
"directoryVariable": "HARNESS_SKILLS_CODEX_DIR",
"project": [".agents", "skills"],
"fallback": [".agents", "skills"],
"runtime": {
"command": "codex",
Expand All @@ -11,6 +12,7 @@
},
"claude": {
"directoryVariable": "HARNESS_SKILLS_CLAUDE_DIR",
"project": [".claude", "skills"],
"configVariable": "CLAUDE_CONFIG_DIR",
"configFallback": ".claude",
"suffix": ["skills"],
Expand All @@ -23,6 +25,7 @@
},
"opencode": {
"directoryVariable": "HARNESS_SKILLS_OPENCODE_DIR",
"project": [".agents", "skills"],
"sharedSkills": true,
"configVariable": "XDG_CONFIG_HOME",
"configFallback": ".config",
Expand All @@ -37,6 +40,7 @@
},
"pi": {
"directoryVariable": "HARNESS_SKILLS_PI_DIR",
"project": [".agents", "skills"],
"sharedSkills": true,
"configVariable": "PI_CODING_AGENT_DIR",
"configFallback": ".pi/agent",
Expand All @@ -50,6 +54,7 @@
},
"antigravity": {
"directoryVariable": "HARNESS_SKILLS_ANTIGRAVITY_DIR",
"project": [".agents", "skills"],
"fallback": [".gemini", "antigravity-cli", "skills"],
"runtime": {
"command": "agy",
Expand All @@ -61,6 +66,7 @@
},
"grok": {
"directoryVariable": "HARNESS_SKILLS_GROK_DIR",
"project": [".agents", "skills"],
"sharedSkills": true,
"fallback": [".grok", "skills"],
"runtime": {
Expand Down
105 changes: 104 additions & 1 deletion scripts/harness-targets.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { pathIsWithin, physicalPathKey, targetPathsOverlap } from "./install-paths.mjs";

export function configuredPath(value, fallback, label, home = homedir()) {
const path = value ?? fallback;
Expand Down Expand Up @@ -32,3 +34,104 @@ export function resolveHarnessRoots(registry, { env = process.env, home = homedi
}
return { skills, native, legacy, sharedRoot, externalClaudeRoot };
}

const TRANSACTION_SEGMENT = /^\.harness-skills-/;
// Directories under which some harness scans for skills recursively. Segments arrive lower-cased on
// case-insensitive platforms, so the names here are lower-case.
const DISCOVERY_PARENTS = [".agents", ".claude", ".cursor", ".codex", ".gemini", ".agent", ".pi", ".opencode", ".grok"];

function enclosingDiscoveryRoot(segments) {
for (let index = 0; index < segments.length - 1; index += 1) {
if (DISCOVERY_PARENTS.includes(segments[index]) && segments[index + 1] === "skills") {
return `${segments[index]}/skills`;
}
}
return undefined;
}

// An override that resolveHarnessRoots would reject must not block a project install, which ignores overrides.
export function withoutInvalidOverrides(registry, env, home = homedir()) {
const cleaned = { ...env };
for (const config of Object.values(registry)) {
for (const variable of [config.directoryVariable, config.configVariable]) {
if (!variable || !cleaned[variable]) continue;
try {
configuredPath(cleaned[variable], "", variable, home);
} catch {
delete cleaned[variable];
}
}
}
return cleaned;
}

// Every target and its transaction storage must physically stay inside the project, away from the mstack
// package, and out of user-level skill roots. Compare physical paths so symlinks and junctions cannot
// redirect a write, and check both directions so nesting is caught either way.
// Returns `assertProjectPath(label, path)`, the single boundary check. The installer calls it again on
// every concrete transaction path (stage, backup, failed) because a link can sit below the storage roots.
export function validateProjectTargets({ projectDir, targets, userRoots, packageRoot }) {
const projectKey = physicalPathKey(projectDir);
const packageKey = packageRoot ? physicalPathKey(packageRoot) : undefined;
const userKeys = userRoots.map(physicalPathKey);
const check = (label, path) => {
const key = physicalPathKey(path);
if (!pathIsWithin(projectKey, key)) throw new Error(`--project ${label} resolves outside the project directory: ${path} -> ${key}`);
if (packageKey && targetPathsOverlap(key, packageKey)) throw new Error(`--project ${label} overlaps the mstack package: ${path}`);
const user = userKeys.find((userKey) => targetPathsOverlap(key, userKey));
if (user) throw new Error(`--project would overlap a user-level skill root: ${label} ${path} and ${user}`);
};
for (const [harness, target] of Object.entries(targets)) {
check(`${harness} skill root`, target);
for (const category of ["backups", "failed", "stage"]) {
// Mirrors the installer: storage sits beside the skills root, one level above each skill directory.
check(`${harness} ${category} storage`, join(dirname(target), `.harness-skills-${category}`));
}
}
const contained = userKeys.find((userKey) => pathIsWithin(projectKey, userKey));
if (contained) throw new Error(`--project cannot contain a user-level skill root: ${projectDir} contains ${contained}`);
return check;
}

// Project roots come only from the registry's `project` path; user-scope overrides never apply.
export function resolveProjectRoots(registry, projectDir) {
const roots = {};
for (const [harness, config] of Object.entries(registry)) {
const parts = config.project;
if (!Array.isArray(parts) || parts.length === 0 || parts.some((part) =>
typeof part !== "string" || !part.trim() || part === "." || part === ".." || /[\\/]/.test(part))) {
throw new Error(`Harness ${harness} has no safe project skill root`);
}
roots[harness] = join(projectDir, ...parts);
}
return roots;
}

export function validateProjectDir(value, { packageRoot, home = homedir(), cwd = process.cwd() } = {}) {
const expanded = value === "~" ? home : /^~[\\/]/.test(value) ? join(home, value.slice(2)) : value;
const project = resolve(cwd, expanded);
let status;
try {
status = statSync(project);
} catch {
throw new Error(`--project must name an existing directory: ${project}`);
}
if (!status.isDirectory()) throw new Error(`--project must name a directory: ${project}`);
// Judge the physical, case-normalized path: a symlink or an uppercase spelling must not slip past.
const physical = physicalPathKey(project);
const segments = physical.split(/[\\/]/).filter(Boolean);
if (segments.some((segment) => TRANSACTION_SEGMENT.test(segment))) {
throw new Error(`--project cannot be inside installer transaction storage: ${project}`);
}
const discoveryRoot = enclosingDiscoveryRoot(segments);
if (discoveryRoot) {
throw new Error(`--project cannot be inside a skill discovery root (${discoveryRoot}): ${project}`);
}
if (physical === physicalPathKey(home)) {
throw new Error("--project cannot be the home directory; omit --project for a user-level install");
}
if (packageRoot && pathIsWithin(physicalPathKey(packageRoot), physical)) {
throw new Error(`--project cannot be inside the mstack package: ${project}`);
}
return project;
}
Loading
Loading