Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f2e7f53
skills: read Claude Code plugins, Codex system skills and linked fold…
santoshkumarradha Sep 23, 2026
449368b
skills: gofmt
santoshkumarradha Sep 23, 2026
2c3c7e8
skills: fixture names, the attachment door leaves the ledger, e2e hel…
santoshkumarradha Sep 23, 2026
f4175b5
skills: the plugin exclusion test checks its control first
santoshkumarradha Sep 23, 2026
75d6814
skills: a plugin reads the skills its manifest or marketplace entry n…
santoshkumarradha Sep 23, 2026
9b7be7e
changes: the skills-from-other-tools entry
santoshkumarradha Sep 23, 2026
21ed5b6
e2e, manual: the carried-skills line is 'skills ·' and folds into the…
santoshkumarradha Sep 23, 2026
8779189
e2e: the relevance eval counts a skill the run opened, one folder per…
santoshkumarradha Sep 23, 2026
6ff612e
tui3: the carried-skills line stays under the message, above the work…
santoshkumarradha Sep 23, 2026
81971bb
tui3: the no-shelf picker test reads the reason the clipped row keeps
santoshkumarradha Sep 23, 2026
df203d9
e2e: the tide and ledger skills carry their answer, so the model does…
santoshkumarradha Sep 23, 2026
11b0a7a
Merge remote-tracking branch 'origin/santos/dev2' into task/skills-fo…
santoshkumarradha Sep 23, 2026
c5b77a6
session: the catalog asks for the skill before any other tool; the ev…
santoshkumarradha Sep 23, 2026
76443df
skills: the attachment rides the facts photograph, and the picker ask…
santoshkumarradha Sep 23, 2026
1bef1ca
tui3: the chip press test runs the clearing door it is handed
santoshkumarradha Sep 23, 2026
2f87cbc
tui3: the shelf read keeps its place in the door line
santoshkumarradha Sep 23, 2026
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
117 changes: 63 additions & 54 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
Expand All @@ -29,7 +30,6 @@ import (
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/search"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/skills"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/subharness"
"github.com/Agent-Field/codeaf/internal/trace"
Expand Down Expand Up @@ -950,12 +950,11 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// memory row is on, which is what makes "memory off makes no calls" a
// fact about the wiring instead of a branch every caller has to keep.
Memory: proc.Memory,
// AND WHETHER THERE IS A SHELF THIS SESSION CANNOT REACH, which is
// only ever true with the line above nil. It is measured here, beside
// the decision that causes it, because the prompt cannot walk six
// folders on every render and because a sentence about a setting
// belongs to the door that read the setting.
SkillsAwaitMemory: skillsWaitingOnMemory(proc.Memory, workspace),
// AND THE SKILL SHELF, which is the line above when memory is on and a
// shelf of the skill folders alone when it is off ([v3SkillShelf]):
// the skills a person installed for another harness are not memory,
// and turning memory off never asked for them to go.
Skills: proc.skillShelf(),
// And the file the old memory lived in, carried into the store on the
// first turn and then renamed out of the way. It is named here rather
// than derived down there for the reason every other path is.
Expand Down Expand Up @@ -1139,7 +1138,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// The pass is idempotent — an unchanged disk journals nothing — so an open
// costs one scan and no writes, and a skill edited since the last open is
// re-read before the model ever sees the shelf.
importForeignSkillsBeforeFirstMessage(proc.Memory, workspace)
importForeignSkillsBeforeFirstMessage(proc.skillShelf(), workspace)

// AND THIS PROCESS STARTS KEEPING TIME. Any open window takes the store's
// lock and runs the pass; the OS timer is the backup for "no terminal open"
Expand All @@ -1166,72 +1165,82 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
}, nil
}

// v3SavedEffort is the rung this conversation was last left on, read back off
// its own folder, and "" for a session that has none — a fresh conversation, a
// build before the field existed, or a launch with no folder at all.
//
// A UNREADABLE FILE IS ABSENCE AND NEVER A FAILURE, exactly as [session.LoadMeta]
// answers everything else about a folder: the rung is a convenience, and a
// launch that refused to open because it could not read one would be the
// convenience costing the thing it was meant to serve.
// importForeignSkillsBeforeFirstMessage runs the foreign-skill import pass
// against the conversation's own store, in place: every SKILL.md folder a
// against the conversation's own shelf, in place: every SKILL.md folder a
// person already has for another harness becomes one active skill fact whose
// artifact is the ORIGINAL directory, before the first message is built. The
// resident reconciler keeps the same pass behind its gate for the processes
// that tick; a launch runs it on the open itself, because a shelf that
// arrives after the first message is a shelf the first conversation cannot
// use.
//
// A launch with no store has no shelf and runs no pass — the same nil answer
// the catalog already gives when memory is off — and a home that cannot be
// resolved is skipped, never fatal: a scan that finds nothing must not be the
// reason a conversation does not open.
// skillsWaitingOnMemory reports whether this machine holds skills that this
// session cannot reach, which is the case exactly when memory is off and a
// scanned folder holds at least one skill that would have loaded.
//
// IT IS THE DIFFERENCE BETWEEN TWO SILENCES. With memory on the catalog speaks
// for itself and this is false; with memory off and no folders it is false too,
// because a person with no skills must not be told about a setting they have no
// use for. It is true only in the case that produced the defect: a person with
// skills on disk, told by the chat that codeaf has no such mechanism.
//
// A scan that fails is not a shelf. Discovery already answers a missing home,
// an unreadable folder and a malformed SKILL.md as absence rather than as an
// error, and a launch must not turn any of those into a sentence claiming a
// shelf exists.
func skillsWaitingOnMemory(memory *store.Store, workspace string) bool {
if memory != nil {
return false
// A launch with no shelf runs no pass, and a home that cannot be resolved is
// skipped, never fatal: a scan that finds nothing must not be the reason a
// conversation does not open.
func importForeignSkillsBeforeFirstMessage(shelf *store.Store, workspace string) {
if shelf == nil {
return
}
homeDir, err := home.Login()
if err != nil {
return false
return
}
found, err := skills.Discover(skills.Options{ProjectDir: workspace, HomeDir: homeDir})
resident.ReconcileImportedSkills(shelf, workspace, homeDir)
}

// v3SkillShelf is the store the skill shelf lives in for one process: the
// memory store when there is one, and with memory off a store of its own in a
// fresh temporary folder, which the process removes when it closes. The
// second answer is the folder it made, so the close knows what to remove; it
// is empty when the shelf is the memory store.
//
// MEMORY OFF IS NOT SKILLS OFF. The setting promises a conversation that
// carries nothing about the person across conversations and makes no memory
// calls, and the skills a person installed for Claude Code or Codex are
// neither: they are folders on disk that say nothing about them. So the shelf
// is still built, from those folders and nothing else, by the same import
// pass that fills it with memory on — the folders stay the one source of
// truth either way, and nothing is written into the memory database the
// person turned off. The shelf is thrown away with the process, so it never
// becomes a second, older copy of what the folders say.
//
// A shelf that cannot be made is no shelf: the conversation opens without
// skills, the way it would have with no skill folders at all, and the /skill
// picker says on each row that it cannot attach.
func v3SkillShelf(memory *store.Store) (*store.Store, string) {
if memory != nil {
return memory, ""
}
dir, err := os.MkdirTemp("", "codeaf-skills-")
if err != nil {
return false
return nil, ""
}
for _, skill := range found {
if skill.Name != "" && skill.Description != "" {
return true
}
shelf, err := store.Open(filepath.Join(dir, "shelf.db"))
if err != nil {
_ = os.RemoveAll(dir)
return nil, ""
}
return false
return shelf, dir
}

func importForeignSkillsBeforeFirstMessage(memory *store.Store, workspace string) {
if memory == nil {
return
}
homeDir, err := home.Login()
if err != nil {
return
// skillShelf is the shelf this process's conversations read, falling back to
// the memory store for a process assembled without [v3SkillShelf] (the
// suite's own processes are built by hand).
func (p *v3Process) skillShelf() *store.Store {
if p.Skills != nil {
return p.Skills
}
resident.ReconcileImportedSkills(memory, workspace, homeDir)
return p.Memory
}

// v3SavedEffort is the rung this conversation was last left on, read back off
// its own folder, and "" for a session that has none — a fresh conversation, a
// build before the field existed, or a launch with no folder at all.
//
// A UNREADABLE FILE IS ABSENCE AND NEVER A FAILURE, exactly as [session.LoadMeta]
// answers everything else about a folder: the rung is a convenience, and a
// launch that refused to open because it could not read one would be the
// convenience costing the thing it was meant to serve.
func v3SavedEffort(place session.Place) string {
dir := strings.TrimSpace(place.Dir)
if dir == "" {
Expand Down
19 changes: 19 additions & 0 deletions cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ type v3Process struct {
// answer to give. Each conversation still gets its own memory pass and its
// own context, which is per-agent already.
Memory *store.Store
// Skills is the skill shelf every conversation this process opens reads:
// the Memory store itself when memory is on, and otherwise a store of its
// own that holds nothing but the skills the folders on disk hold
// ([v3SkillShelf]). It is profile-scoped for Memory's reason, and one
// handle for its reason too.
Skills *store.Store
// skillsDir is the folder the memory-off shelf lives in, removed with it
// at close; empty when the shelf is the Memory store.
skillsDir string
// Artifacts is the deliverables index — one file per machine, and /export
// and /files must resolve the same one the session's own products record
// themselves in.
Expand Down Expand Up @@ -205,6 +214,7 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
Conns: v3Connect(settings.ProfileDir),
LaunchDir: launchDir,
}
process.Skills, process.skillsDir = v3SkillShelf(process.Memory)
process.startPlaceSweep()
return process, nil
}
Expand Down Expand Up @@ -482,6 +492,15 @@ func (p *v3Process) closeAll() {
if p.Memory != nil {
_ = p.Memory.Close()
}
// The memory-off shelf goes with the process that built it: it was only
// ever a reading of the skill folders, and the next launch reads them
// again.
if p.skillsDir != "" {
if p.Skills != nil {
_ = p.Skills.Close()
}
_ = os.RemoveAll(p.skillsDir)
}
}

// ── the agent-building seam ─────────────────────────────────────────────────
Expand Down
70 changes: 70 additions & 0 deletions cmd/codeaf/chatv3_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,73 @@ func TestAForeignSkillIsOnTheShelfBeforeTheFirstMessage(t *testing.T) {
func TestALaunchWithNoStoreSkipsTheShelfPassWithoutPanic(t *testing.T) {
importForeignSkillsBeforeFirstMessage(nil, t.TempDir())
}

// MEMORY OFF IS NOT SKILLS OFF. A launch whose memory row is off opens no
// memory store at all, and still reaches the skills a person installed for
// another harness: the process builds a shelf of the folders alone, the
// launch imports into it before the first message, and the process removes it
// when it closes, so nothing about the folders outlives the process that read
// them.
func TestAMemoryOffLaunchStillHasTheSkillShelf(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("CODEAF_HOME", t.TempDir())
profile := t.TempDir()
t.Setenv("CODEAF_PROFILE_DIR", profile)
t.Setenv("OPENROUTER_API_KEY", "test-key")
if err := os.WriteFile(filepath.Join(profile, "config.json"), []byte(`{"memory.enabled": "off"}`), 0o600); err != nil {
t.Fatal(err)
}
proc, err := openV3Process("chat")
if err != nil {
t.Fatalf("the process did not open: %v", err)
}
t.Cleanup(proc.closeAll)
dir := aForeignSkill(t)

launch, err := openV3Launch(proc, v3Options{Model: "test/model", Workspace: t.TempDir()})
if err != nil {
t.Fatalf("the launch did not open: %v", err)
}
if launch.Config.Memory != nil {
t.Fatal("a launch with memory off was handed a memory store")
}
if launch.Config.Skills == nil {
t.Fatal("a launch with memory off was handed no skill shelf")
}
facts, err := launch.Config.Skills.SkillFacts(store.FactActive, 50)
if err != nil {
t.Fatalf("the memory-off shelf did not read: %v", err)
}
found := false
for _, fact := range facts {
found = found || fact.Artifact == dir
}
if !found {
t.Fatalf("the skill folder is not on the memory-off shelf: %+v", facts)
}

shelfDir := proc.skillsDir
if shelfDir == "" {
t.Fatal("the memory-off shelf has no folder of its own to remove")
}
proc.closeAll()
if _, err := os.Stat(shelfDir); !os.IsNotExist(err) {
t.Fatalf("the memory-off shelf outlived its process at %s (%v)", shelfDir, err)
}
}

// AND WITH MEMORY ON THERE IS ONE SHELF, the memory store itself: no second
// database is opened beside the one the conversation remembers into.
func TestAMemoryOnLaunchReadsSkillsFromTheMemoryStore(t *testing.T) {
proc := v3TestProcess(t)
launch, err := openV3Launch(proc, v3Options{Model: "test/model", Workspace: t.TempDir()})
if err != nil {
t.Fatalf("the launch did not open: %v", err)
}
if launch.Config.Memory == nil || launch.Config.Skills != launch.Config.Memory {
t.Fatalf("with memory on the skill shelf is %p and memory is %p, want the same store", launch.Config.Skills, launch.Config.Memory)
}
if proc.skillsDir != "" {
t.Fatalf("a memory-on process made a second shelf at %s", proc.skillsDir)
}
}
34 changes: 34 additions & 0 deletions docs/changes/unreleased/1396-skills-from-other-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
kind: changed
title: Claude Code plugin and Codex skills reach the chat, memory off included
pr: 1396
surface: [chat, engine, remote]
invalidates:
- "With memory.enabled off, the chat had no skill shelf and no use_skill, and the catalog said so in one line naming the setting (#1380). Memory off no longer turns skills off: the shelf is built from the skill folders for that process and thrown away when it ends, use_skill is on the belt, and the switched-off line is gone."
- "Claude Code plugin skills were never read, because they live in each plugin's own install folder and not in ~/.claude/skills. The skills of every installed and enabled plugin are now read, only those the plugin names in its manifest or marketplace entry, and only for the project a project-scoped plugin was installed in."
- "Codex's bundled skills in ~/.codex/skills/.system were not read. They are now, ranked below every hand-kept skill and every plugin skill."
- "A skill folder that is a link to a folder was passed over. It is read now, which is how installers that keep one copy and link it into every tool's folder reach codeaf."
- "The skill catalog in the system prompt listed at most fifty skills, ordered by recent use, and a skill reached a message only when the message shared words with its description. The catalog now lists every skill in a stable order with its description clipped to 160 characters, up to 12 KiB, then the remaining names up to 2 KiB, and the model opens the one that fits with use_skill. The per-message word match still runs as a first pass."
- "The /skill picker read skill folders from disk while the conversation read the shelf, so a row could look attachable and not be. The picker now reads the conversation's own shelf, and on the default launch through the local session host the attach, detach and list calls cross the host connection instead of being missing from it."
- "The skill shelf held at most 100 skills. It holds 400."
- "The manual said a dim `skills carried:` line sits under the message. On the chat surface the line reads `skills · <names>`, and once the answer landed the `▸ worked` chip swallowed it, where even an opened chip did not show it. It now stays under the message with the chip below it; only the headless --once door prints `skills carried:`."
---
A person asked their own codeaf to use a skill and it used none. Three things
stood in the way, each correct from the inside. Memory was off, and the shelf
lived in the memory store, so there was no shelf. Most of their Claude Code
skills arrived inside plugins, which unpack into folders the scan never looked
at. And the skills that were found reached a message only when its words
matched a description, which a request in the person's own words rarely does.

Memory off promises that nothing about the person is carried between
conversations. Skill folders on disk are not about the person, so the shelf is
now built from them either way; with memory off it lives in a temporary store
the process removes on close, and the folders stay the one source of truth.

Plugins are read the way Claude Code decides what is live: installed in
installed_plugins.json, enabled in the layered enabledPlugins settings, and only
the skill folders the plugin names. A plugin skill keeps its bare folder name
and never outranks a skill placed by hand.

The catalog is now the model's menu, the shape Claude Code uses: every skill's
name and purpose in the stable prefix, and the body fetched on demand.
Loading
Loading