Harness spec: one path form, the file tools' texts, the list tree, skills

filesystem.md, spec 3.0.0-draft, and the CLI made to match:

- Paths: absolute in every answer except a result's bracket (context.md),
  which keeps the call's path. glob and grep now give full paths.
- A missing file is `No such file or directory: {path}` for every tool
  that needs it, with no hint after it: in a chat a hidden file must read
  as a missing one. The CLI's "does not exist. Use write to create it." and
  "Write it first." are gone; an edit of a directory is `Is a directory`,
  glob over a file `Not a directory`.
- write/edit refusals, exactly: "{path} exists and has not been read in
  this session. Read it first." and "{path} changed since you last read
  it. Read it again before changing it." (no "on disk"), absolute path.
- A listing sorts names in code-point order; `list` with a depth is a
  tree indented two spaces a level, marks as in a single listing, cut at
  400 lines with a line saying so.
- Skills: where they are found and in what order on each side (the
  session's own place first, the person's next, what is shared last), and
  the name rule ^[a-z0-9][a-z0-9-]{0,63}$ that skill_manage checks. The
  CLI no longer makes names with _ or .; such skills still load.

README's 3.0.0 notes say so; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-28 01:20:42 +00:00
1 parent 4ca6af12b1
commit b1dcaded72
15 files changed
+167 -41

No files matched your search

+5 -1
View File
@@ -25,7 +25,11 @@ export interface Skill {
export const SUPPORT_DIRS = ["references", "templates", "scripts", "assets"] as const
const SKIP = new Set([".git", ".github", ".archive", ".venv", "venv", "node_modules", "site-packages", "__pycache__", ...SUPPORT_DIRS])
export const NAME = /^[a-z0-9][a-z0-9._-]*$/
/** A name skill_manage makes (filesystem.md, *Skills*; the Agent Skills format's rule): lowercase
* letters, digits and -, starting with a letter or a digit, at most MAX_NAME. */
export const NAME = /^[a-z0-9][a-z0-9-]*$/
/** A category directory's name. */
export const CATEGORY = /^[a-z0-9][a-z0-9._-]*$/
export const MAX_NAME = 64
export const MAX_DESCRIPTION = 1024
export const INDEX_DESCRIPTION = 60
+1 -1
View File
@@ -10,7 +10,7 @@ import { hunks, sha256 } from "../session/results.ts"
async function planEdit(a: { path: string; old: string; new: string; all?: boolean }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
if (!(await guardExisting(abs, ctx))) throw new ToolError(`No such file or directory: ${abs}`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
+1 -1
View File
@@ -10,7 +10,7 @@ import { hunks, sha256 } from "../session/results.ts"
async function planEdits(a: { path: string; edits: { old: string; new: string; all?: boolean }[] }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
if (!(await guardExisting(abs, ctx))) throw new ToolError(`No such file or directory: ${abs}`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
+3 -2
View File
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path"
import { z } from "zod"
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
import { byListing } from "./search.ts"
import { sha256 } from "../session/results.ts"
const MAX_LINES = 2000
@@ -25,12 +26,12 @@ export const readTool = defineTool({
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const st = await stat(abs).catch(() => undefined)
if (!st) throw new ToolError(`${a.path} does not exist`)
if (!st) throw new ToolError(`No such file or directory: ${abs}`)
if (st.isDirectory()) {
const entries = await readdir(abs, { withFileTypes: true })
const lines = entries
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort((x, y) => Number(y.endsWith("/")) - Number(x.endsWith("/")) || x.localeCompare(y))
.sort(byListing)
return { output: lines.join("\n") || "(empty directory)", title: `${showPath(abs, ctx)}/ · ${lines.length} entries` }
}
const file = Bun.file(abs)
+23 -9
View File
@@ -18,8 +18,8 @@ async function listFiles(dir: string, glob: string | undefined, ctx: ToolContext
// Spawning in a directory that does not exist fails as "posix_spawn 'rg' ENOENT", which reads
// as ripgrep missing.
const st = await stat(dir).catch(() => undefined)
if (!st) throw new ToolError(`${showPath(dir, ctx)} does not exist.`)
if (!st.isDirectory()) throw new ToolError(`${showPath(dir, ctx)} is a file, not a directory.`)
if (!st) throw new ToolError(`No such file or directory: ${dir}`)
if (!st.isDirectory()) throw new ToolError(`Not a directory: ${dir}`)
if (hasRg) {
const args = ["rg", "--files", "--hidden", "--glob", "!.git"]
if (glob) args.push("--glob", glob)
@@ -51,7 +51,8 @@ export const globTool = defineTool({
const files = await listFiles(dir, a.pattern, ctx)
const withTime = await Promise.all(files.map(async (f) => ({ f, t: (await stat(join(dir, f)).catch(() => undefined))?.mtimeMs ?? 0 })))
withTime.sort((x, y) => y.t - x.t)
const shown = withTime.slice(0, 200).map((x) => showPath(join(dir, x.f), ctx))
// Absolute, as every answer names a path (filesystem.md, *Paths*).
const shown = withTime.slice(0, 200).map((x) => join(dir, x.f))
const more = files.length > 200 ? `\n(${files.length - 200} more — narrow the pattern)` : ""
return { output: (shown.join("\n") || "No files found.") + more, title: `${a.pattern} · ${files.length} files`, meta: { hits: files.length } }
},
@@ -74,6 +75,7 @@ export const grepTool = defineTool({
async run(a, ctx) {
const target = absPath(a.path ?? ".", ctx)
const lines: string[] = []
if (!(await stat(target).catch(() => undefined))) throw new ToolError(`No such file or directory: ${target}`)
if (hasRg) {
const args = ["rg", "--line-number", "--no-heading", "--color", "never", "--hidden", "--glob", "!.git", "--max-columns", "400", "--max-columns-preview"]
if (a.ignore_case) args.push("-i")
@@ -82,7 +84,7 @@ export const grepTool = defineTool({
args.push("-e", a.pattern, target)
const r = await run(args, ctx.cwd, ctx.signal)
if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`)
for (const l of r.out.split("\n")) if (l) lines.push(l.startsWith(target) ? showPath(target, ctx) + l.slice(target.length) : l)
for (const l of r.out.split("\n")) if (l) lines.push(l)
} else {
let re: RegExp
try {
@@ -90,14 +92,13 @@ export const grepTool = defineTool({
} catch (e) {
throw new ToolError(`invalid regex: ${(e as Error).message}`)
}
const st = await stat(target).catch(() => undefined)
if (!st) throw new ToolError(`${a.path} does not exist`)
const st = await stat(target)
const files = st.isDirectory() ? (await listFiles(target, a.include, ctx)).map((f) => join(target, f)) : [target]
for (const f of files) {
const text = await Bun.file(f).text().catch(() => "")
if (text.includes("\0")) continue
text.split("\n").forEach((l, i) => {
if (re.test(l)) lines.push(`${showPath(f, ctx)}:${i + 1}:${l.slice(0, 400)}`)
if (re.test(l)) lines.push(`${f}:${i + 1}:${l.slice(0, 400)}`)
})
if (lines.length > 1000) break
}
@@ -109,6 +110,17 @@ export const grepTool = defineTool({
},
})
/** A listing's order (filesystem.md, *A listing*): directories first, then by name in code-point
* order. */
export function byListing(x: string, y: string): number {
const dirs = Number(y.endsWith("/")) - Number(x.endsWith("/"))
if (dirs) return dirs
const a = [...x].map((c) => c.codePointAt(0)!)
const b = [...y].map((c) => c.codePointAt(0)!)
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i]! - b[i]!
return a.length - b.length
}
export const listTool = defineTool({
...described("list"),
access: "list",
@@ -136,7 +148,7 @@ export const listTool = defineTool({
}
const out: string[] = []
const walk = (parent: string, indent: string) => {
const kids = [...(tree.get(parent) ?? [])].sort((x, y) => Number(y.endsWith("/")) - Number(x.endsWith("/")) || x.localeCompare(y))
const kids = [...(tree.get(parent) ?? [])].sort(byListing)
for (const k of kids) {
if (out.length >= 400) return
out.push(indent + k)
@@ -144,6 +156,8 @@ export const listTool = defineTool({
}
}
walk("", "")
return { output: out.join("\n") || "(empty)", title: `${relative(ctx.root, dir) || "."}/ · ${files.length} files`, meta: { hits: files.length } }
// Cut at 400 lines, saying so (filesystem.md, *A listing*).
const more = out.length >= 400 ? "\n(more entries — list a directory further down)" : ""
return { output: (out.join("\n") || "(empty)") + more, title: `${relative(ctx.root, dir) || "."}/ · ${files.length} files`, meta: { hits: files.length } }
},
})
+2 -2
View File
@@ -59,11 +59,11 @@ export const showTool = defineTool({
let size: number
try {
const st = statSync(abs)
if (!st.isFile()) throw new ToolError(`${showPath(abs, ctx)} is not a file.`)
if (!st.isFile()) throw new ToolError(st.isDirectory() ? `Is a directory: ${abs}` : `${showPath(abs, ctx)} is not a file.`)
size = st.size
} catch (e) {
if (e instanceof ToolError) throw e
throw new ToolError(`${showPath(abs, ctx)} does not exist. Write it first.`)
throw new ToolError(`No such file or directory: ${abs}`)
}
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
const text = await readText(abs)
+4 -4
View File
@@ -5,7 +5,7 @@ import { basename, dirname, join, relative } from "node:path"
import { paths } from "../config/paths.ts"
import { z } from "zod"
import { threatMessage } from "../memory/threats.ts"
import { within, findSkill, globalSkillsDir, INDEX_DESCRIPTION, linkedFiles, loadSkills, MAX_DESCRIPTION, MAX_FILE, MAX_NAME, NAME, type Skill, skillFile, splitFrontmatter, SUPPORT_DIRS } from "../skill/index.ts"
import { CATEGORY, within, findSkill, globalSkillsDir, INDEX_DESCRIPTION, linkedFiles, loadSkills, MAX_DESCRIPTION, MAX_FILE, MAX_NAME, NAME, type Skill, skillFile, splitFrontmatter, SUPPORT_DIRS } from "../skill/index.ts"
import { replace } from "./replace-text.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
@@ -144,7 +144,7 @@ export const skillManageTool = defineTool({
const ops = a.operations as Op[]
if (ops.some((o) => o.action === "delete" || o.action === "revert") && ops.length > 1) throw new ToolError(`${ops.find((o) => o.action === "delete" || o.action === "revert")!.action} must be the only operation in its call.`)
// Every name is checked before anything else: it becomes a directory name below.
for (const o of ops) if (!NAME.test(o.name) || o.name.length > MAX_NAME) throw new ToolError(`"${o.name}" is not a valid skill name: lowercase letters, digits, - _ . — starting with a letter or digit, up to ${MAX_NAME}.`)
for (const o of ops) if (!NAME.test(o.name) || o.name.length > MAX_NAME) throw new ToolError(`"${o.name}" is not a valid skill name: lowercase letters, digits and -, starting with a letter or digit, up to ${MAX_NAME}.`)
const skills = skillsOf(ctx)
if (ops[0]!.action === "revert") {
const name = ops[0]!.name
@@ -197,11 +197,11 @@ export const skillManageTool = defineTool({
const fail = (m: string): never => {
throw new ToolError(at + m)
}
if (!NAME.test(o.name) || o.name.length > MAX_NAME) fail(`"${o.name}" is not a valid skill name: lowercase letters, digits, - _ . — starting with a letter or digit, up to ${MAX_NAME}.`)
if (!NAME.test(o.name) || o.name.length > MAX_NAME) fail(`"${o.name}" is not a valid skill name: lowercase letters, digits and -, starting with a letter or digit, up to ${MAX_NAME}.`)
const existing = created.get(o.name) ?? findSkill(skills, o.name)
if (o.action === "create") {
if (existing) fail(`a skill "${o.name}" already exists (${existing.dir}). Use patch.`)
if (o.category && (!NAME.test(o.category) || o.category.includes("/"))) fail(`"${o.category}" is not a valid category.`)
if (o.category && (!CATEGORY.test(o.category) || o.category.includes("/"))) fail(`"${o.category}" is not a valid category.`)
const text = o.content ?? (o.description?.trim() && o.instructions?.trim() ? composeSkill(o.name, o.description, o.instructions) : fail("create needs description and instructions (or content: the whole SKILL.md)."))
const bad = validateSkill(text, o.name) ?? threatMessage(text, "The skill")
if (bad) fail(bad)
+9 -8
View File
@@ -7,15 +7,16 @@ import { absPath, defineTool, showPath, ToolError, type ToolContext } from "./to
import { described } from "../harness.ts"
import { hunks, lineCount, sha256 } from "../session/results.ts"
/** Refuse to overwrite a file the model has not read, or one changed on disk since it was read. */
export async function guardExisting(abs: string, display: string, ctx: ToolContext): Promise<boolean> {
/** Refuse to overwrite a file the model has not read, or one changed on disk since it was read. The
* texts are the spec's (filesystem.md, *Errors*), with the absolute path. */
export async function guardExisting(abs: string, ctx: ToolContext): Promise<boolean> {
const st = await stat(abs).catch(() => undefined)
if (!st) return false
if (!st.isFile()) throw new ToolError(`${display} is not a regular file`)
if (!ctx.readFiles.has(abs)) throw new ToolError(`${display} exists and has not been read in this session. Read it first.`)
if (st.isDirectory()) throw new ToolError(`Is a directory: ${abs}`)
if (!st.isFile()) throw new ToolError(`${abs} is not a regular file`)
if (!ctx.readFiles.has(abs)) throw new ToolError(`${abs} exists and has not been read in this session. Read it first.`)
const seen = ctx.fileStamps.get(abs)
if (seen !== undefined && st.mtimeMs > seen + 1)
throw new ToolError(`${display} changed on disk since you last read it. Read it again before changing it.`)
if (seen !== undefined && st.mtimeMs > seen + 1) throw new ToolError(`${abs} changed since you last read it. Read it again before changing it.`)
return true
}
@@ -43,13 +44,13 @@ export const writeTool = defineTool({
async preview(a, ctx) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
const before = (await guardExisting(abs, display, ctx)) ? await Bun.file(abs).text() : ""
const before = (await guardExisting(abs, ctx)) ? await Bun.file(abs).text() : ""
return createTwoFilesPatch(display, display, before, a.content, "", "", { context: 3 })
},
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
const existed = await guardExisting(abs, display, ctx)
const existed = await guardExisting(abs, ctx)
const before = existed ? await Bun.file(abs).text() : ""
await mkdir(dirname(abs), { recursive: true })
await Bun.write(abs, a.content)