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
78 changes: 63 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,45 @@ to match how Construct's loader and flake discover skills, and its own
`README.md`/`CREDITS.md` track provenance and the flattening rule. It is
excluded from the root flake's `excludedDirs` and packaged separately.

## Vendored Orca skills (`orca-skills/`)

`orca-skills/` vendors three skills from [Orca](https://github.com/stablyai/orca)
— `computer-use`, `orca-cli`, `orchestration` — **verbatim and unmodified**
(MIT, third-party — Standard §4.2). Never hand-edit them; a new revision comes
in by re-copying upstream's `SKILL.md` and updating `CREDITS.md`. The generic
leaf names `computer-use` and `orchestration` are reserved: Orca's own CLI looks
its skills up by exact leaf name, so a future Spacecraft skill must not claim
either.

**They are OPT-IN in the flake (`spacecraft.construct.enableOrca`, default off),
and must stay off on any host that runs the Orca app.** Orca installs and
updates its own copies; a copy served from the Nix store makes its updater fail
in a way no amount of re-vendoring fixes:

- Orca's scanner (`observeSkillPackage`, in the app's `app.asar`) throws
`skill-package-link` on any file with `nlink != 1`. Store optimisation
hardlinks identical files, so every store-served `SKILL.md` eventually has
nlink > 1 — ours sat at 5–7. The throw is caught and reported as status
`unrecognized`: the "The copy here doesn't match the official version" row in
Settings → Update skills.
- Even past that check, store files are mode 444 and
`classifyHomeSkillTopology` marks an unwritable path `read-only`, which the
updater also skips.
- **Byte-identity does not help.** `computer-use` and `orchestration` were
byte-for-byte the official revisions and were flagged just the same. Any note
claiming a revision pin clears the warning is wrong.

On an Orca host the arrangement is: `enableOrca = false`, plus
`spacecraft.construct.perSkillLinks.enable = true` so `~/.agents/skills` is a
real directory with room for `orca skills install` (`npx skills add`) to own
`computer-use/`, `orca-cli/`, `orchestration/` as real, writable directories.
The module never clobbers a real directory it did not create, and prunes only
symlinks pointing into its own tree.

Turn `enableOrca` on only where nothing else provides these skills — no Orca
app, an air-gapped host, a container image. `packages.skills-with-orca` builds
that merged tree.

## Grok skills (`grok-skills/`)

Grok uses a **flat** bundle format — `SKILL.md` and any `assets/` / `references/`
Expand Down Expand Up @@ -311,17 +350,22 @@ byte-identical to it.

## Local agent fan-out (Home Manager hosts)

Local fan-out is managed by **Home Manager**, not by the assistant. Each
per-harness skill path is a real directory provisioned by Home Manager with
per-skill symlinks that chain through the Nix store to this repo:
Local fan-out is managed by **Home Manager**, not by the assistant. Every
per-harness path is a symlink to the canonical `~/.agents/skills`, which under
`mutablePointer` chains through a mutable pointer into the store:

```
~/.claude/skills/<skill>
→ /nix/store/<hash>-home-manager-files/.claude/skills/<skill>
→ /nix/store/<hash>-hm_<skill>
→ /spacecraft-software/construct/<skill>
~/.claude/skills → ~/.agents/skills
~/.agents/skills → ~/.local/state/construct/current
~/.local/state/construct/current → …/pinned → /nix/store/<hash>-construct-skills
```

With `perSkillLinks.enable = true` the middle step changes shape: `~/.agents/skills`
is a **real directory** whose entries are per-skill symlinks into
`…/construct/current/<skill>`. Same content, but names the module does not carry
stay free for another installer to own — which is what an Orca host needs (see
*Vendored Orca skills* above).

Paths populated by Home Manager: `~/.claude/skills/`, `~/.codex/skills/`,
`~/.ai/skills/`, `~/.agent/skills/`. Gemini CLI's scan path is Home Manager's
responsibility on this host as well — the assistant does not provision it.
Expand All @@ -331,9 +375,9 @@ unified path layout for new consumers: install once to `~/.agents/skills/` and
symlink every per-harness path (`~/.claude/skills`, `~/.gemini/skills`,
`~/.codex/skills`, `~/.ai/skills`, `~/.agent/skills`) to that canonical
location. Grok skills install separately to `~/.grok/skills/` because of
their different bundle layout. The hand-written per-skill HM config currently
in use on this host predates the flake and should be migrated to
`spacecraft.construct.enable = true` at the maintainer's convenience.
their different bundle layout. This host is already on the module
(`spacecraft.construct` in `bravais/users/mj/home.nix`, with `mutablePointer`
and a longer `agentPaths` list).

**After a PR merges, Home Manager must be rebuilt** before per-harness paths
resolve to the new content. The maintainer runs the rebuild manually
Expand All @@ -344,13 +388,17 @@ Verify after rebuild:

```sh
readlink -f ~/.claude/skills/spacecraft-steelbore-standard
# → /spacecraft-software/construct/spacecraft-steelbore-standard
# → /nix/store/<hash>-construct-skills/spacecraft-steelbore-standard
sha256sum ~/.claude/skills/spacecraft-steelbore-standard/SKILL.md \
/spacecraft-software/construct/spacecraft-steelbore-standard/SKILL.md
```

If the symlink still resolves into a stale `/nix/store/<old-hash>-hm_*` path
(for example after a repo rename), the Home Manager config has not yet been
rebuilt against the new repo path. Until it is, agents read the previous
generation's content even though `origin/main` is current.
The tree is a store copy, not a link back into the checkout, so the check is
byte-equality against the working tree rather than the resolved path. If the
two hashes differ (or the path still resolves into a stale
`/nix/store/<old-hash>-hm_*` from an earlier layout), Home Manager has not been
rebuilt against the current commit — agents read the previous generation's
content even though `origin/main` is ahead.

The assistant performs no `rsync`, no symlink setup, and no
`home-manager switch`. Its responsibility ends at opening the PR.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ the §2 catalogue above and ship no `.zip`/`.skill` bundles (Google's `android`
CLI installs them from upstream). See the section's README for provenance and
the upstream→vendored path mapping.

### Orca skills

Three skills from **[Orca](https://github.com/stablyai/orca)** — `computer-use`,
`orca-cli`, `orchestration` — are vendored verbatim (MIT, © Lovecast Inc.) under
[`orca-skills/`](orca-skills/). They are third-party, so they stay out of the §2
catalogue above.

They are **opt-in** (`spacecraft.construct.enableOrca = true`) and should stay
off wherever the Orca app is installed: Orca installs and updates its own
copies, and its updater rejects a copy served from the Nix store — store files
are hardlinked (`nlink != 1`) and read-only, both of which it refuses to
verify, so it reports every such skill as `Unrecognized` no matter how exactly
the bytes match. Pair `enableOrca = false` with
`spacecraft.construct.perSkillLinks.enable = true`, which renders
`~/.agents/skills` as a real directory so Orca has somewhere writable to own
them. Turn `enableOrca` on where nothing else provides these skills;
`packages.skills-with-orca` builds that tree.

### Perplexity skills

Perplexity caps an uploaded skill zip at **100 files**, which the
Expand Down
166 changes: 148 additions & 18 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,26 @@
skillNamesIn (self + "/android-skills")
else
[];
# Vendored Orca skills — same open-standard SKILL.md format, merged into
# the canonical tree unconditionally (unlike the opt-in Android set): the
# `orca` CLI looks its skills up by exact leaf name, so they have to be
# present wherever an agent reads skills from, not behind a toggle.
# Vendored Orca skills — same open-standard SKILL.md format, but OPT-IN
# (`enableOrca`), not merged by default.
#
# They were unconditional until it turned out that installing them from
# the Nix store is what BREAKS Orca's own updater. Orca scans the agent
# skill directories and, in `observeSkillPackage`, throws
# `skill-package-link` on any file with `nlink !== 1`; the catch turns
# that into status `unrecognized`, which is the "The copy here doesn't
# match the official version" row in Settings → Update skills. Store
# files are hardlinked by store optimisation (ours sat at nlink 5–7), and
# they are mode 444 besides, so `classifyHomeSkillTopology` would mark
# them `read-only` even if the byte check passed. No pin of the vendored
# revision can clear it — the copies were byte-identical to the official
# rev and still flagged.
#
# So the default is now: Orca ships these skills, Orca installs them
# (`orca skills install`, which is `npx skills add` underneath) into a
# real writable directory, and Orca updates them. Turn `enableOrca` on
# for a host with no Orca app, where the vendored copies are the only
# ones. See `orca-skills/CREDITS.md` for the provenance procedure.
orcaSkills =
if builtins.pathExists (self + "/orca-skills") then
skillNamesIn (self + "/orca-skills")
Expand Down Expand Up @@ -84,15 +100,16 @@
mkMerged pkgs outName [ { inherit source; names = skillList; } ];

# The base tree every non-Grok consumer starts from: the cross-platform
# skills plus the vendored Orca ones. Leaf names don't collide —
# cross-platform skills are all spacecraft-* / gnu-* / microsoft-* /
# steelbore-*, and the three Orca leaves are distinct from those — so a
# flat merge is safe. `orca-skills/CREDITS.md` records that the generic
# Orca leaf names (`computer-use`, `orchestration`) are reserved and must
# not be claimed by a future Spacecraft skill.
baseSources = [
# skills, plus the vendored Orca ones when the caller asks for them. Leaf
# names don't collide — cross-platform skills are all spacecraft-* /
# gnu-* / microsoft-* / steelbore-*, and the three Orca leaves are
# distinct from those — so a flat merge is safe. `orca-skills/CREDITS.md`
# records that the generic Orca leaf names (`computer-use`,
# `orchestration`) are reserved and must not be claimed by a future
# Spacecraft skill, whether or not this tree carries them.
baseSources = orca: [
{ source = self; names = crossPlatformSkills; }
] ++ nixpkgs.lib.optional (orcaSkills != [])
] ++ nixpkgs.lib.optional (orca && orcaSkills != [])
{ source = self + "/orca-skills"; names = orcaSkills; };

# THE skill tree builder. Every consumer goes through this — the
Expand All @@ -105,16 +122,16 @@
# pins" against "what is installed" must pass one derivation from here to
# both sides; comparing `packages.skills` against a separately-built
# module tree compares two nixpkgs, and reports drift forever.
mkSkills = { pkgs, android ? false, grok ? false }:
mkSkills = { pkgs, android ? false, grok ? false, orca ? false }:
if grok then
mkCombined pkgs (self + "/grok-skills") grokSkills "construct-grok-skills"
else if android && androidSkills != [] then
mkMerged pkgs "construct-skills-with-android"
(baseSources ++ [
(baseSources orca ++ [
{ source = self + "/android-skills"; names = androidSkills; }
])
else
mkMerged pkgs "construct-skills" baseSources;
mkMerged pkgs "construct-skills" (baseSources orca);
in {

# ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -153,6 +170,15 @@
android = true;
};
}
// nixpkgs.lib.optionalAttrs (orcaSkills != [ ]) {
# The vendored Orca leaves merged in, for a host with no Orca app to
# install and update them itself. See `orcaSkills` above for why that
# is the exception rather than the default.
skills-with-orca = mkSkills {
inherit pkgs;
orca = true;
};
}
// nixpkgs.lib.optionalAttrs (grokSkills != [ ]) {
skills-grok = mkSkills {
inherit pkgs;
Expand Down Expand Up @@ -219,11 +245,30 @@
enableAndroid = lib.mkEnableOption
"vendored Google Android skills (merged into ~/.agents/skills/)";

enableOrca = lib.mkEnableOption ''
the vendored Orca skills (`computer-use`, `orca-cli`,
`orchestration`) in the canonical tree.

Leave this OFF on any host that runs the Orca app. Orca installs
and updates its own copies, and it refuses to touch a copy it
cannot verify: its scanner throws `skill-package-link` on a file
with `nlink != 1`, which every Nix-store file eventually is once
store optimisation hardlinks it, and reports the skill as
`Unrecognized` in Settings → Update skills. Matching the vendored
bytes to the official revision does not help — a byte-identical
copy is flagged just the same, and the store's 444 modes make the
path `read-only` for the updater regardless.

Turn it on where nothing else provides these skills (no Orca app
installed, an air-gapped host, a container image)
'';

package = lib.mkOption {
type = lib.types.package;
default = mkSkills {
inherit pkgs;
android = cfg.enableAndroid;
orca = cfg.enableOrca;
};
defaultText = lib.literalExpression
"this flake's own combined skill tree (Android merged in when enableAndroid)";
Expand All @@ -237,8 +282,9 @@
from this flake's — and any pinned-vs-live comparison built on
store paths reports drift that is not there.

Setting this supersedes `enableAndroid` for the installed tree;
`enableAndroid` then only selects this option's default.
Setting this supersedes `enableAndroid` and `enableOrca` for
the installed tree; those flags then only select this option's
default.
'';
};

Expand Down Expand Up @@ -282,6 +328,28 @@
};
};

perSkillLinks.enable = lib.mkEnableOption ''
rendering `~/.agents/skills` as a REAL directory holding one
symlink per skill, instead of a single directory symlink into the
store (or into the mutable pointer).

The skills themselves are unchanged — each entry still resolves
into the same tree. What changes is that the directory has room
for entries this module does not own, which is the whole point:
an installer that manages its own skills (Orca's `orca skills
install`, i.e. `npx skills add`) needs somewhere writable to put
them, and a directory symlink into the store gives it nowhere.

A real directory already sitting on a skill's name is left alone
and reported, never replaced. Pruning is limited to symlinks that
point INTO the tree, so a foreign skill survives every rebuild.

Caveat under `mutablePointer`: the links point through
`<stateDir>/current`, so swapping the pointer between rebuilds
still changes what every existing link resolves to, but a skill
the swapped-in tree ADDS is not linked until the next activation
'';

agentPaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = defaultAgentPaths;
Expand All @@ -296,7 +364,11 @@
config = lib.mkMerge [
# Store-link install (the default). Home Manager owns
# ~/.agents/skills outright and every change needs a rebuild.
(lib.mkIf (cfg.enable && !cfg.mutablePointer.enable) {
# Skipped under perSkillLinks, where the activation below renders
# the directory instead — the two cannot both own that path.
(lib.mkIf (cfg.enable
&& !cfg.mutablePointer.enable
&& !cfg.perSkillLinks.enable) {
home.file.".agents/skills".source = cfg.package;
})

Expand Down Expand Up @@ -343,11 +415,69 @@
$DRY_RUN_CMD rm -rf "${stateDir}/current"
fi
$DRY_RUN_CMD ln -sfn "${stateDir}/pinned" "${stateDir}/current"
''
# Under perSkillLinks the entry below renders ~/.agents/skills
# as a real directory, so the pointer stops at <stateDir> and
# this link is not made.
+ lib.optionalString (!cfg.perSkillLinks.enable) ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve foreign skills during mutable-pointer activation

When both mutablePointer.enable and perSkillLinks.enable are true, this conditional suppresses only the final ln; the earlier block at lines 394–397 still moves any real ~/.agents/skills directory aside on every activation. After the first activation, that directory is precisely the canonical directory containing Orca-owned real skills, so the next rebuild removes those skills from every harness path and recreates the canonical directory without them. Gate the move-aside block on !cfg.perSkillLinks.enable as well.

AGENTS.md reference: AGENTS.md:L286-L291

Useful? React with 👍 / 👎.


$DRY_RUN_CMD ln -sfn "${stateDir}/current" "$HOME/.agents/skills"
'';
})

# Per-skill links. ~/.agents/skills becomes a real directory whose
# entries point into the tree one skill at a time, leaving the
# names this module does not carry free for another installer to
# own — which is what Orca's updater needs, since it refuses any
# copy it cannot verify and a store copy is hardlinked (nlink != 1)
# and read-only (444). See `enableOrca` for the full mechanism.
(lib.mkIf (cfg.enable && cfg.perSkillLinks.enable) {
home.activation."spacecraft-construct-per-skill-links" =
lib.hm.dag.entryAfter
([ "linkGeneration" ]
++ lib.optional cfg.mutablePointer.enable
"spacecraft-construct-skill-pointer") ''
src="${if cfg.mutablePointer.enable
then "${stateDir}/current"
else cfg.package}"
canonical="$HOME/.agents/skills"

# A directory symlink from a generation before this option
# was on has to go before the directory can be made.
if [ -L "$canonical" ]; then
$DRY_RUN_CMD rm -f "$canonical"
fi
$DRY_RUN_CMD mkdir -p "$canonical"

for d in "$src"/*/; do
[ -d "$d" ] || continue
n="$(basename "$d")"
t="$canonical/$n"
# A REAL directory on that name belongs to whoever put it
# there. Report it and move on — clobbering another
# installer's skill is how this module would become the
# thing that breaks Orca's updater from the other side.
if [ -e "$t" ] && [ ! -L "$t" ]; then
echo "construct: $t is a real directory owned by another installer — left as is" >&2
continue
fi
$DRY_RUN_CMD ln -sfn "$src/$n" "$t"
done

# Prune only what this module made: a link INTO the tree
# whose skill the tree no longer carries. A link pointing
# anywhere else is someone else's and is left alone.
for l in "$canonical"/*; do
[ -L "$l" ] || continue
case "$(readlink "$l")" in
"$src"/*)
[ -d "$src/$(basename "$l")" ] || $DRY_RUN_CMD rm -f "$l"
Comment on lines +473 to +474

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prune links owned by the previous package

When perSkillLinks.enable is used without mutablePointer and a package update removes a skill, its existing link still targets the previous immutable store path while $src now names the new store path. The case therefore does not match, so the removed skill remains visible indefinitely and keeps the old package reachable; pruning needs to recognize links created from prior Construct package generations rather than only the current $src.

AGENTS.md reference: AGENTS.md:L290-L291

Useful? React with 👍 / 👎.

;;
esac
done
'';
})

(lib.mkIf cfg.enable {
# Per-harness directory symlinks. Done via activation so the
# symlink can point at the home-relative ~/.agents/skills
Expand Down
Loading
Loading